From fc4dda023b4126c2d8462f2518631c13792811b7 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Tue, 7 May 2019 08:06:15 +0700 Subject: [PATCH 1/9] winning submission from challenge 30089708 - Topcoder Project Service - Milestones pause/resume --- migrations/20190502_status_history_create.sql | 29 ++ postman.json | 72 ++++ src/constants.js | 2 + src/models/milestone.js | 30 ++ src/models/statusHistory.js | 35 ++ src/routes/index.js | 6 + src/routes/milestones/create.spec.js | 8 +- src/routes/milestones/status.pause.js | 91 ++++ src/routes/milestones/status.pause.spec.js | 394 +++++++++++++++++ src/routes/milestones/status.resume.js | 102 +++++ src/routes/milestones/status.resume.spec.js | 405 ++++++++++++++++++ src/routes/milestones/update.spec.js | 22 +- swagger.yaml | 131 ++++++ 13 files changed, 1312 insertions(+), 15 deletions(-) create mode 100644 migrations/20190502_status_history_create.sql create mode 100644 src/models/statusHistory.js create mode 100644 src/routes/milestones/status.pause.js create mode 100644 src/routes/milestones/status.pause.spec.js create mode 100644 src/routes/milestones/status.resume.js create mode 100644 src/routes/milestones/status.resume.spec.js diff --git a/migrations/20190502_status_history_create.sql b/migrations/20190502_status_history_create.sql new file mode 100644 index 00000000..cac38750 --- /dev/null +++ b/migrations/20190502_status_history_create.sql @@ -0,0 +1,29 @@ +-- +-- Create table status history +-- + +CREATE TABLE status_history ( + id bigint, + "reference" character varying(45) NOT NULL, + "referenceId" bigint NOT NULL, + "status" character varying(45) NOT NULL, + "comment" text, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "createdBy" integer NOT NULL, + "updatedBy" integer NOT NULL +); + +CREATE SEQUENCE status_history_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE status_history_id_seq OWNED BY status_history.id; + +ALTER TABLE ONLY status_history ALTER COLUMN id SET DEFAULT nextval('status_history_id_seq'::regclass); + +ALTER TABLE ONLY status_history + ADD CONSTRAINT status_history_pkey PRIMARY KEY (id); \ No newline at end of file diff --git a/postman.json b/postman.json index b4b0a1b6..6d1a589d 100644 --- a/postman.json +++ b/postman.json @@ -4253,6 +4253,78 @@ } }, "response": [] + }, + { + "name": "Pause Milestone", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"comment\": \"Comment\"\r\n\t}\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines/1/milestones/2/status/pause", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1", + "milestones", + "2", + "status", + "pause" + ] + } + }, + "response": [] + }, + { + "name": "Resume Milestone", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"comment\": \"Comment\"\r\n\t}\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines/1/milestones/2/status/resume", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1", + "milestones", + "2", + "status", + "resume" + ] + } + }, + "response": [] } ] }, diff --git a/src/constants.js b/src/constants.js index d2b7ee41..dae629f7 100644 --- a/src/constants.js +++ b/src/constants.js @@ -131,6 +131,8 @@ export const BUS_API_EVENT = { MILESTONE_TRANSITION_ACTIVE: 'connect.action.timeline.milestone.transition.active', // When milestone is marked as completed MILESTONE_TRANSITION_COMPLETED: 'connect.action.timeline.milestone.transition.completed', + // When milestone is marked as paused + MILESTONE_TRANSITION_PAUSED: 'connect.action.timeline.milestone.transition.paused', // When milestone is waiting for customers's input MILESTONE_WAITING_CUSTOMER: 'connect.action.timeline.milestone.waiting.customer', diff --git a/src/models/milestone.js b/src/models/milestone.js index 76246a52..49fa2f93 100644 --- a/src/models/milestone.js +++ b/src/models/milestone.js @@ -1,4 +1,5 @@ import moment from 'moment'; +import models from '../models'; /* eslint-disable valid-jsdoc */ /** @@ -82,6 +83,35 @@ module.exports = (sequelize, DataTypes) => { }); }, }, + hooks: { + afterCreate: (milestone, options) => models.StatusHistory.create({ + reference: 'milestone', + referenceId: milestone.id, + status: milestone.status, + comment: null, + createdBy: milestone.createdBy, + updatedBy: milestone.updatedBy, + }, + { + transaction: options.transaction, + }), + afterUpdate: (milestone, options) => { + if (milestone.changed().includes('status')) { + return models.StatusHistory.create({ + reference: 'milestone', + referenceId: milestone.id, + status: milestone.status, + comment: options.comment || null, + createdBy: milestone.createdBy, + updatedBy: milestone.updatedBy, + }, + { + transaction: options.transaction, + }); + } + return Promise.resolve(); + }, + }, }); return Milestone; diff --git a/src/models/statusHistory.js b/src/models/statusHistory.js new file mode 100644 index 00000000..e9970b8b --- /dev/null +++ b/src/models/statusHistory.js @@ -0,0 +1,35 @@ +/* eslint-disable valid-jsdoc */ + +import _ from 'lodash'; +import { MILESTONE_STATUS } from '../constants'; + +module.exports = function defineStatusHistory(sequelize, DataTypes) { + const StatusHistory = sequelize.define('StatusHistory', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + reference: { type: DataTypes.STRING, allowNull: false }, + referenceId: { type: DataTypes.STRING, allowNull: false }, + status: { + type: DataTypes.STRING, + allowNull: false, + validate: { + isIn: [_.values(MILESTONE_STATUS)], + }, + }, + comment: DataTypes.TEXT, + createdBy: { type: DataTypes.INTEGER, allowNull: false }, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedBy: { type: DataTypes.INTEGER, allowNull: false }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + }, { + tableName: 'status_history', + paranoid: false, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + indexes: [], + classMethods: {}, + }); + + return StatusHistory; +}; diff --git a/src/routes/index.js b/src/routes/index.js index 4e8176af..6faa8ef3 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -176,6 +176,12 @@ router.route('/v4/timelines/:timelineId(\\d+)/milestones/:milestoneId(\\d+)') .patch(require('./milestones/update')) .delete(require('./milestones/delete')); +router.route('/v4/timelines/:timelineId(\\d+)/milestones/:milestoneId(\\d+)/status/pause') + .patch(require('./milestones/status.pause')); + +router.route('/v4/timelines/:timelineId(\\d+)/milestones/:milestoneId(\\d+)/status/resume') + .patch(require('./milestones/status.resume')); + router.route('/v4/timelines/metadata/milestoneTemplates') .post(require('./milestoneTemplates/create')) .get(require('./milestoneTemplates/list')); diff --git a/src/routes/milestones/create.spec.js b/src/routes/milestones/create.spec.js index ab1424ca..b9cec474 100644 --- a/src/routes/milestones/create.spec.js +++ b/src/routes/milestones/create.spec.js @@ -146,7 +146,7 @@ describe('CREATE milestone', () => { name: 'milestone 1', duration: 2, startDate: '2018-05-03T00:00:00.000Z', - status: 'open', + status: 'draft', type: 'type1', details: { detail1: { @@ -168,7 +168,7 @@ describe('CREATE milestone', () => { name: 'milestone 2', duration: 3, startDate: '2018-05-04T00:00:00.000Z', - status: 'open', + status: 'draft', type: 'type2', order: 2, plannedText: 'plannedText 2', @@ -183,7 +183,7 @@ describe('CREATE milestone', () => { name: 'milestone 3', duration: 4, startDate: '2018-05-04T00:00:00.000Z', - status: 'open', + status: 'draft', type: 'type3', order: 3, plannedText: 'plannedText 3', @@ -211,7 +211,7 @@ describe('CREATE milestone', () => { startDate: '2018-05-05T00:00:00.000Z', endDate: '2018-05-07T00:00:00.000Z', completionDate: '2018-05-08T00:00:00.000Z', - status: 'open', + status: 'draft', type: 'type4', details: { detail1: { diff --git a/src/routes/milestones/status.pause.js b/src/routes/milestones/status.pause.js new file mode 100644 index 00000000..abc4796f --- /dev/null +++ b/src/routes/milestones/status.pause.js @@ -0,0 +1,91 @@ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import validateTimeline from '../../middlewares/validateTimeline'; +import { MILESTONE_STATUS, BUS_API_EVENT } from '../../constants'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + + +const schema = { + params: { + timelineId: Joi.number().integer().positive().required(), + milestoneId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + comment: Joi.string().max(512).required(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + // Validate and get projectId from the timelineId param, + // and set to request params for checking by the permissions middleware + validateTimeline.validateTimelineIdParam, + permissions('milestone.edit'), + (req, res, next) => { + const where = { + timelineId: req.params.timelineId, + id: req.params.milestoneId, + }; + + const entityToUpdate = { + updatedBy: req.authUser.userId, + }; + const comment = req.body.param.comment; + + let original; + let updated; + + return models.sequelize.transaction(transaction => + // Find the milestone + models.Milestone.findOne({ where }) + .then((milestone) => { + // Not found + if (!milestone) { + const apiErr = new Error(`Milestone not found for milestone id ${req.params.milestoneId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // status already on pause + if (milestone.status === MILESTONE_STATUS.PAUSED) { + const apiErr = new Error('Milestone status already paused'); + apiErr.status = 422; + return Promise.reject(apiErr); + } + + original = _.omit(milestone.toJSON(), ['deletedAt', 'deletedBy']); + + entityToUpdate.status = MILESTONE_STATUS.PAUSED; + entityToUpdate.id = milestone.id; + + // Update + return milestone.update(entityToUpdate, { comment, transaction }); + }) + .then((updatedMilestone) => { + updated = _.omit(updatedMilestone.toJSON(), 'deletedAt', 'deletedBy'); + }), + ) + .then(() => { + // Send event to bus + req.log.debug('Sending event to RabbitMQ bus for milestone %d', updated.id); + req.app.services.pubsub.publish(BUS_API_EVENT.MILESTONE_TRANSITION_PAUSED, + { original, updated }, + { correlationId: req.id }, + ); + + req.app.emit(BUS_API_EVENT.MILESTONE_TRANSITION_PAUSED, + { req, original, updated }); + + res.json(util.wrapResponse(req.id)); + return Promise.resolve(true); + }) + .catch(next); + }, +]; diff --git a/src/routes/milestones/status.pause.spec.js b/src/routes/milestones/status.pause.spec.js new file mode 100644 index 00000000..edef1acc --- /dev/null +++ b/src/routes/milestones/status.pause.spec.js @@ -0,0 +1,394 @@ +/* eslint-disable no-unused-expressions */ +/** + * Tests for status.pause.js + */ +import chai from 'chai'; +import _ from 'lodash'; +import request from 'supertest'; +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +chai.should(); + +describe('Status Pause Milestone', () => { + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate([ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + lastActivityAt: 1, + lastActivityUserId: '1', + deletedAt: '2018-05-15T00:00:00Z', + }, + ]) + .then(() => { + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: 1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: 1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]).then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: 1, + name: 'test project phase 1', + 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 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: 2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ])) + .then(() => + // Create timelines + models.Timeline.bulkCreate([ + { + name: 'name 1', + description: 'description 1', + startDate: '2018-05-02T00:00:00.000Z', + endDate: '2018-06-12T00:00:00.000Z', + reference: 'project', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 2', + description: 'description 2', + startDate: '2018-05-12T00:00:00.000Z', + endDate: '2018-06-13T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 3', + description: 'description 3', + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-06-14T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + deletedAt: '2018-05-14T00:00:00.000Z', + }, + ]).then(() => models.Milestone.bulkCreate([ + { + id: 1, + timelineId: 1, + name: 'Milestone 1', + duration: 2, + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-14T00:00:00.000Z', + completionDate: '2018-05-15T00:00:00.000Z', + status: 'draft', + type: 'type1', + details: { + detail1: { + subDetail1A: 1, + subDetail1B: 2, + }, + detail2: [1, 2, 3], + }, + order: 1, + plannedText: 'plannedText 1', + activeText: 'activeText 1', + completedText: 'completedText 1', + blockedText: 'blockedText 1', + createdBy: 1, + updatedBy: 2, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 2, + timelineId: 1, + name: 'Milestone 2', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type2', + order: 2, + plannedText: 'plannedText 2', + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 3, + timelineId: 1, + name: 'Milestone 3', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type3', + order: 3, + plannedText: 'plannedText 3', + activeText: 'activeText 3', + completedText: 'completedText 3', + blockedText: 'blockedText 3', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 4, + timelineId: 1, + name: 'Milestone 4', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type4', + order: 4, + plannedText: 'plannedText 4', + activeText: 'activeText 4', + completedText: 'completedText 4', + blockedText: 'blockedText 4', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 5, + timelineId: 1, + name: 'Milestone 5', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type5', + order: 5, + plannedText: 'plannedText 5', + activeText: 'activeText 5', + completedText: 'completedText 5', + blockedText: 'blockedText 5', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + deletedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 6, + timelineId: 2, // Timeline 2 + name: 'Milestone 6', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type5', + order: 1, + plannedText: 'plannedText 6', + activeText: 'activeText 6', + completedText: 'completedText 6', + blockedText: 'blockedText 6', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + ]))) + .then(() => done()); + }); + }); + }); + + after(testUtil.clearDb); + describe('PATCH /timelines/{timelineId}/milestones/{milestoneId}/status/pause', () => { + const body = { + param: { + comment: 'comment', + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1/status/pause') + .send(body) + .expect(403, done); + }); + + + it('should return 403 for member who is not in the project', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1/status/pause') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 404 for non-existed timeline', (done) => { + request(server) + .patch('/v4/timelines/1234/milestones/1/status/pause') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted timeline', (done) => { + request(server) + .patch('/v4/timelines/3/milestones/1/status/pause') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for non-existed Milestone', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/111/status/pause') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted Milestone', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/5/status/pause') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 422 for invalid timelineId param', (done) => { + request(server) + .patch('/v4/timelines/0/milestones/1/status/pause') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 422 for invalid milestoneId param', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/0/status/pause') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 422 for missing comment', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.comment; + request(server) + .patch('/v4/timelines/1/milestones/1/status/pause') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(422, done); + }); + + it('should return 200 and status should update to paused', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1/status/pause') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end(() => { + models.Milestone.findById(1) + .then((milestone) => { + milestone.status.should.be.eql('paused'); + done(); + }); + }); + }); + + it('should have one status history created with multiple sequencial status paused messages', function fn(done) { + this.timeout(10000); + request(server) + .patch('/v4/timelines/1/milestones/1/status/pause') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end(() => { + request(server) + .patch('/v4/timelines/1/milestones/1/status/pause') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(422) + .end(() => { + done(); + }); + }); + }); + }); +}); diff --git a/src/routes/milestones/status.resume.js b/src/routes/milestones/status.resume.js new file mode 100644 index 00000000..a3e4bd67 --- /dev/null +++ b/src/routes/milestones/status.resume.js @@ -0,0 +1,102 @@ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import validateTimeline from '../../middlewares/validateTimeline'; +import { MILESTONE_STATUS, BUS_API_EVENT } from '../../constants'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + + +const schema = { + params: { + timelineId: Joi.number().integer().positive().required(), + milestoneId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + comment: Joi.string().max(512).optional(), + }), + }, +}; + +module.exports = [ + validate(schema), + // Validate and get projectId from the timelineId param, + // and set to request params for checking by the permissions middleware + validateTimeline.validateTimelineIdParam, + permissions('milestone.edit'), + (req, res, next) => { + const where = { + timelineId: req.params.timelineId, + id: req.params.milestoneId, + }; + + const entityToUpdate = { + updatedBy: req.authUser.userId, + }; + + const comment = req.body.param ? req.body.param.comment : ''; + let original; + let updated; + + return models.sequelize.transaction(transaction => + // Find the milestone + models.Milestone.findOne({ where }) + .then((milestone) => { + // Not found + if (!milestone) { + const apiErr = new Error(`Milestone not found for milestone id ${req.params.milestoneId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // status already on pause + if (milestone.status !== MILESTONE_STATUS.PAUSED) { + const apiErr = new Error('Milestone status isn\'t paused'); + apiErr.status = 422; + return Promise.reject(apiErr); + } + + original = _.omit(milestone.toJSON(), ['deletedAt', 'deletedBy']); + + const whereStatus = { referenceId: milestone.id.toString() }; + return models.StatusHistory.findAll({ + whereStatus, + order: [['createdAt', 'desc']], + attributes: ['status'], + limit: 2, + raw: true, + }) + .then((statusHistory) => { + if (statusHistory.length === 2) { + entityToUpdate.status = statusHistory[1].status; + entityToUpdate.id = milestone.id; + } + // Update + return milestone.update(entityToUpdate, { comment, transaction }); + }); + }) + .then((updatedMilestone) => { + updated = _.omit(updatedMilestone.toJSON(), 'deletedAt', 'deletedBy'); + }), + ) + .then(() => { + // Send event to bus + req.log.debug('Sending event to RabbitMQ bus for milestone %d', updated.id); + req.app.services.pubsub.publish(BUS_API_EVENT.MILESTONE_TRANSITION_ACTIVE, + { original, updated }, + { correlationId: req.id }, + ); + + req.app.emit(BUS_API_EVENT.MILESTONE_TRANSITION_ACTIVE, + { req, original, updated }); + + res.json(util.wrapResponse(req.id)); + return Promise.resolve(true); + }) + .catch(next); + }, +]; diff --git a/src/routes/milestones/status.resume.spec.js b/src/routes/milestones/status.resume.spec.js new file mode 100644 index 00000000..8ff45878 --- /dev/null +++ b/src/routes/milestones/status.resume.spec.js @@ -0,0 +1,405 @@ +/* eslint-disable no-unused-expressions */ +/** + * Tests for status.resume.js + */ +import chai from 'chai'; +import request from 'supertest'; +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +chai.should(); + +describe('Status resume Milestone', () => { + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate([ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + lastActivityAt: 1, + lastActivityUserId: '1', + deletedAt: '2018-05-15T00:00:00Z', + }, + ]) + .then(() => { + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: 1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: 1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]).then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: 1, + name: 'test project phase 1', + 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 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: 2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ])) + .then(() => { + models.StatusHistory.bulkCreate([ + { + reference: 'milestone', + referenceId: '1', + status: 'active', + comment: 'comment', + createdBy: 1, + createdAt: '2018-05-15T00:00:00Z', + updatedBy: 1, + updatedAt: '2018-05-15T00:00:00Z', + }, + { + reference: 'milestone', + referenceId: '1', + status: 'paused', + comment: 'comment', + createdBy: 1, + createdAt: '2018-05-16T00:00:00Z', + updatedBy: 1, + updatedAt: '2018-05-16T00:00:00Z', + }, + ]); + }) + .then(() => + // Create timelines + models.Timeline.bulkCreate([ + { + name: 'name 1', + description: 'description 1', + startDate: '2018-05-02T00:00:00.000Z', + endDate: '2018-06-12T00:00:00.000Z', + reference: 'project', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 2', + description: 'description 2', + startDate: '2018-05-12T00:00:00.000Z', + endDate: '2018-06-13T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 3', + description: 'description 3', + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-06-14T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + deletedAt: '2018-05-14T00:00:00.000Z', + }, + ]).then(() => models.Milestone.bulkCreate([ + { + id: 1, + timelineId: 1, + name: 'Milestone 1', + duration: 2, + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-14T00:00:00.000Z', + completionDate: '2018-05-15T00:00:00.000Z', + status: 'paused', + type: 'type1', + details: { + detail1: { + subDetail1A: 1, + subDetail1B: 2, + }, + detail2: [1, 2, 3], + }, + order: 1, + plannedText: 'plannedText 1', + activeText: 'activeText 1', + completedText: 'completedText 1', + blockedText: 'blockedText 1', + createdBy: 1, + updatedBy: 2, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 2, + timelineId: 1, + name: 'Milestone 2', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type2', + order: 2, + plannedText: 'plannedText 2', + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 3, + timelineId: 1, + name: 'Milestone 3', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type3', + order: 3, + plannedText: 'plannedText 3', + activeText: 'activeText 3', + completedText: 'completedText 3', + blockedText: 'blockedText 3', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 4, + timelineId: 1, + name: 'Milestone 4', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type4', + order: 4, + plannedText: 'plannedText 4', + activeText: 'activeText 4', + completedText: 'completedText 4', + blockedText: 'blockedText 4', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 5, + timelineId: 1, + name: 'Milestone 5', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type5', + order: 5, + plannedText: 'plannedText 5', + activeText: 'activeText 5', + completedText: 'completedText 5', + blockedText: 'blockedText 5', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + deletedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 6, + timelineId: 2, // Timeline 2 + name: 'Milestone 6', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type5', + order: 1, + plannedText: 'plannedText 6', + activeText: 'activeText 6', + completedText: 'completedText 6', + blockedText: 'blockedText 6', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + ]))) + .then(() => done()); + }); + }); + }); + + after(testUtil.clearDb); + describe('PATCH /timelines/{timelineId}/milestones/{milestoneId}/status/resume', () => { + const body = { + param: { + comment: 'comment', + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1/status/resume') + .send(body) + .expect(403, done); + }); + + + it('should return 403 for member who is not in the project', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1/status/resume') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 404 for non-existed timeline', (done) => { + request(server) + .patch('/v4/timelines/1234/milestones/1/status/resume') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted timeline', (done) => { + request(server) + .patch('/v4/timelines/3/milestones/1/status/resume') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for non-existed Milestone', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/111/status/resume') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted Milestone', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/5/status/resume') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 422 for invalid timelineId param', (done) => { + request(server) + .patch('/v4/timelines/0/milestones/1/status/resume') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 422 for invalid milestoneId param', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/0/status/resume') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 200 and status should update to last status', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1/status/resume') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end(() => { + models.Milestone.findById(1) + .then((milestone) => { + milestone.status.should.be.eql('active'); + done(); + }); + }); + }); + + it('should have one status history created with multiple sequencial status resumed messages', function fn(done) { + this.timeout(10000); + request(server) + .patch('/v4/timelines/1/milestones/1/status/resume') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end(() => { + request(server) + .patch('/v4/timelines/1/milestones/1/status/resume') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(422) + .end(() => { + done(); + }); + }); + }); + }); +}); diff --git a/src/routes/milestones/update.spec.js b/src/routes/milestones/update.spec.js index 9a694e73..6bcfa51b 100644 --- a/src/routes/milestones/update.spec.js +++ b/src/routes/milestones/update.spec.js @@ -141,7 +141,7 @@ describe('UPDATE Milestone', () => { startDate: '2018-05-13T00:00:00.000Z', endDate: '2018-05-14T00:00:00.000Z', completionDate: '2018-05-15T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type1', details: { detail1: { @@ -166,7 +166,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 2', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'draft', type: 'type2', order: 2, plannedText: 'plannedText 2', @@ -184,7 +184,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 3', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type3', order: 3, plannedText: 'plannedText 3', @@ -202,7 +202,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 4', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type4', order: 4, plannedText: 'plannedText 4', @@ -220,7 +220,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 5', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type5', order: 5, plannedText: 'plannedText 5', @@ -239,7 +239,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 6', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type5', order: 1, plannedText: 'plannedText 6', @@ -266,7 +266,7 @@ describe('UPDATE Milestone', () => { duration: 3, completionDate: '2018-05-16T00:00:00.000Z', description: 'description-updated', - status: 'closed', + status: 'draft', type: 'type1-updated', details: { detail1: { @@ -713,7 +713,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 7', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type7', order: 3, plannedText: 'plannedText 7', @@ -731,7 +731,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 8', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type7', order: 4, plannedText: 'plannedText 8', @@ -786,7 +786,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 7', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type7', order: 2, plannedText: 'plannedText 7', @@ -804,7 +804,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 8', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type7', order: 4, plannedText: 'plannedText 8', diff --git a/swagger.yaml b/swagger.yaml index a74dbb94..489a2ad0 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -1715,6 +1715,86 @@ paths: description: Invalid input schema: $ref: '#/definitions/ErrorModel' + '/timelines/{timelineId}/milestones/{milestoneId}/status/pause': + parameters: + - $ref: '#/parameters/timelineIdParam' + - $ref: '#/parameters/milestoneIdParam' + patch: + tags: + - milestone + operationId: pauseMilestone + security: + - Bearer: [] + description: >- + Update a milestone Status to Paused. All users who can edit the timeline can access this + endpoint. If the status is alread paused it doesnt allow. + responses: + '200': + description: Successfully updated status. + schema: + $ref: '#/definitions/StatusPauseResumeResponse' + '403': + description: No permission or wrong token + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '422': + description: Invalid input or invalid previous status + schema: + $ref: '#/definitions/ErrorModel' + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - name: body + in: body + required: true + schema: + $ref: '#/definitions/MilestonePatchPauseStatus' + '/timelines/{timelineId}/milestones/{milestoneId}/status/resume': + parameters: + - $ref: '#/parameters/timelineIdParam' + - $ref: '#/parameters/milestoneIdParam' + patch: + tags: + - milestone + operationId: resumeMilestone + security: + - Bearer: [] + description: >- + Update a milestone Status to Active. All users who can edit the timeline can access this + endpoint. If the status is different than paused it doesnt allow. + responses: + '200': + description: Successfully updated status. + schema: + $ref: '#/definitions/StatusPauseResumeResponse' + '403': + description: No permission or wrong token + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '422': + description: Invalid input or invalid previous status + schema: + $ref: '#/definitions/ErrorModel' + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - name: body + in: body + required: false + schema: + $ref: '#/definitions/MilestonePatchResumeStatus' /timelines/metadata/milestoneTemplates: get: tags: @@ -4632,6 +4712,22 @@ definitions: blockedText: type: string description: the milestone blocked text + MilestonePauseStatusRequest: + title: Milestone Pause Status object + type: object + required: + - comment + properties: + comment: + type: string + description: the status comment + MilestoneResumeStatusRequest: + title: Milestone Resume Status object + type: object + properties: + comment: + type: string + description: the status comment MilestonePatchRequest: title: Milestone request object type: object @@ -4701,6 +4797,22 @@ definitions: properties: param: $ref: '#/definitions/MilestonePatchRequest' + MilestonePatchPauseStatus: + title: Milestone Pause status body param + type: object + required: + - param + properties: + param: + $ref: '#/definitions/MilestonePauseStatusRequest' + MilestonePatchResumeStatus: + title: Milestone REsume status body param + type: object + required: + - param + properties: + param: + $ref: '#/definitions/MilestoneResumeStatusRequest' Milestone: title: Milestone object allOf: @@ -4756,6 +4868,25 @@ definitions: $ref: '#/definitions/ResponseMetadata' content: $ref: '#/definitions/Milestone' + StatusPauseResumeResponse: + title: Milestone status pause and resume object + type: object + properties: + id: + type: string + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: '#/definitions/ResponseMetadata' MilestoneListResponse: title: Milestone list response object type: object From 74d5a0de7e46b7bf8883e5ae3ea3417affb63bfc Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Tue, 7 May 2019 08:06:36 +0700 Subject: [PATCH 2/9] fix formating --- src/models/milestone.js | 15 +- src/routes/milestones/status.pause.js | 55 ++++--- src/routes/milestones/status.pause.spec.js | 156 ++++++++++---------- src/routes/milestones/status.resume.js | 65 ++++---- src/routes/milestones/status.resume.spec.js | 146 +++++++++--------- 5 files changed, 217 insertions(+), 220 deletions(-) diff --git a/src/models/milestone.js b/src/models/milestone.js index 49fa2f93..389a66e5 100644 --- a/src/models/milestone.js +++ b/src/models/milestone.js @@ -91,10 +91,10 @@ module.exports = (sequelize, DataTypes) => { comment: null, createdBy: milestone.createdBy, updatedBy: milestone.updatedBy, - }, - { - transaction: options.transaction, - }), + }, { + transaction: options.transaction, + }), + afterUpdate: (milestone, options) => { if (milestone.changed().includes('status')) { return models.StatusHistory.create({ @@ -104,10 +104,9 @@ module.exports = (sequelize, DataTypes) => { comment: options.comment || null, createdBy: milestone.createdBy, updatedBy: milestone.updatedBy, - }, - { - transaction: options.transaction, - }); + }, { + transaction: options.transaction, + }); } return Promise.resolve(); }, diff --git a/src/routes/milestones/status.pause.js b/src/routes/milestones/status.pause.js index abc4796f..3fac5b01 100644 --- a/src/routes/milestones/status.pause.js +++ b/src/routes/milestones/status.pause.js @@ -9,7 +9,6 @@ import models from '../../models'; const permissions = tcMiddleware.permissions; - const schema = { params: { timelineId: Joi.number().integer().positive().required(), @@ -24,8 +23,8 @@ const schema = { module.exports = [ validate(schema), - // Validate and get projectId from the timelineId param, - // and set to request params for checking by the permissions middleware + // Validate and get projectId from the timelineId param, + // and set to request params for checking by the permissions middleware validateTimeline.validateTimelineIdParam, permissions('milestone.edit'), (req, res, next) => { @@ -43,45 +42,45 @@ module.exports = [ let updated; return models.sequelize.transaction(transaction => - // Find the milestone - models.Milestone.findOne({ where }) - .then((milestone) => { - // Not found - if (!milestone) { - const apiErr = new Error(`Milestone not found for milestone id ${req.params.milestoneId}`); - apiErr.status = 404; - return Promise.reject(apiErr); - } + // Find the milestone + models.Milestone.findOne({ where }) + .then((milestone) => { + // Not found + if (!milestone) { + const apiErr = new Error(`Milestone not found for milestone id ${req.params.milestoneId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } - // status already on pause - if (milestone.status === MILESTONE_STATUS.PAUSED) { - const apiErr = new Error('Milestone status already paused'); - apiErr.status = 422; - return Promise.reject(apiErr); - } + // status already on pause + if (milestone.status === MILESTONE_STATUS.PAUSED) { + const apiErr = new Error('Milestone status already paused'); + apiErr.status = 422; + return Promise.reject(apiErr); + } - original = _.omit(milestone.toJSON(), ['deletedAt', 'deletedBy']); + original = _.omit(milestone.toJSON(), ['deletedAt', 'deletedBy']); - entityToUpdate.status = MILESTONE_STATUS.PAUSED; - entityToUpdate.id = milestone.id; + entityToUpdate.status = MILESTONE_STATUS.PAUSED; + entityToUpdate.id = milestone.id; - // Update - return milestone.update(entityToUpdate, { comment, transaction }); - }) + // Update + return milestone.update(entityToUpdate, { comment, transaction }); + }) .then((updatedMilestone) => { updated = _.omit(updatedMilestone.toJSON(), 'deletedAt', 'deletedBy'); }), - ) + ) .then(() => { // Send event to bus req.log.debug('Sending event to RabbitMQ bus for milestone %d', updated.id); req.app.services.pubsub.publish(BUS_API_EVENT.MILESTONE_TRANSITION_PAUSED, - { original, updated }, - { correlationId: req.id }, + { original, updated }, + { correlationId: req.id }, ); req.app.emit(BUS_API_EVENT.MILESTONE_TRANSITION_PAUSED, - { req, original, updated }); + { req, original, updated }); res.json(util.wrapResponse(req.id)); return Promise.resolve(true); diff --git a/src/routes/milestones/status.pause.spec.js b/src/routes/milestones/status.pause.spec.js index edef1acc..ade09563 100644 --- a/src/routes/milestones/status.pause.spec.js +++ b/src/routes/milestones/status.pause.spec.js @@ -271,124 +271,124 @@ describe('Status Pause Milestone', () => { it('should return 403 for member who is not in the project', (done) => { request(server) - .patch('/v4/timelines/1/milestones/1/status/pause') - .set({ - Authorization: `Bearer ${testUtil.jwts.member2}`, - }) - .send(body) - .expect(403, done); + .patch('/v4/timelines/1/milestones/1/status/pause') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send(body) + .expect(403, done); }); it('should return 404 for non-existed timeline', (done) => { request(server) - .patch('/v4/timelines/1234/milestones/1/status/pause') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(404, done); + .patch('/v4/timelines/1234/milestones/1/status/pause') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); }); it('should return 404 for deleted timeline', (done) => { request(server) - .patch('/v4/timelines/3/milestones/1/status/pause') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(404, done); + .patch('/v4/timelines/3/milestones/1/status/pause') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); }); it('should return 404 for non-existed Milestone', (done) => { request(server) - .patch('/v4/timelines/1/milestones/111/status/pause') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(404, done); + .patch('/v4/timelines/1/milestones/111/status/pause') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); }); it('should return 404 for deleted Milestone', (done) => { request(server) - .patch('/v4/timelines/1/milestones/5/status/pause') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(404, done); + .patch('/v4/timelines/1/milestones/5/status/pause') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); }); it('should return 422 for invalid timelineId param', (done) => { request(server) - .patch('/v4/timelines/0/milestones/1/status/pause') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(422, done); + .patch('/v4/timelines/0/milestones/1/status/pause') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); }); it('should return 422 for invalid milestoneId param', (done) => { request(server) - .patch('/v4/timelines/1/milestones/0/status/pause') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(422, done); + .patch('/v4/timelines/1/milestones/0/status/pause') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); }); it('should return 422 for missing comment', (done) => { const partialBody = _.cloneDeep(body); delete partialBody.param.comment; request(server) - .patch('/v4/timelines/1/milestones/1/status/pause') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(partialBody) - .expect(422, done); + .patch('/v4/timelines/1/milestones/1/status/pause') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(422, done); }); it('should return 200 and status should update to paused', (done) => { request(server) - .patch('/v4/timelines/1/milestones/1/status/pause') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(body) - .expect(200) - .end(() => { - models.Milestone.findById(1) - .then((milestone) => { - milestone.status.should.be.eql('paused'); - done(); - }); + .patch('/v4/timelines/1/milestones/1/status/pause') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end(() => { + models.Milestone.findById(1) + .then((milestone) => { + milestone.status.should.be.eql('paused'); + done(); }); + }); }); it('should have one status history created with multiple sequencial status paused messages', function fn(done) { this.timeout(10000); request(server) - .patch('/v4/timelines/1/milestones/1/status/pause') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(body) - .expect(200) - .end(() => { - request(server) - .patch('/v4/timelines/1/milestones/1/status/pause') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(body) - .expect(422) - .end(() => { - done(); - }); - }); + .patch('/v4/timelines/1/milestones/1/status/pause') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end(() => { + request(server) + .patch('/v4/timelines/1/milestones/1/status/pause') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(422) + .end(() => { + done(); + }); + }); }); }); }); diff --git a/src/routes/milestones/status.resume.js b/src/routes/milestones/status.resume.js index a3e4bd67..f75d04a4 100644 --- a/src/routes/milestones/status.resume.js +++ b/src/routes/milestones/status.resume.js @@ -9,7 +9,6 @@ import models from '../../models'; const permissions = tcMiddleware.permissions; - const schema = { params: { timelineId: Joi.number().integer().positive().required(), @@ -24,8 +23,8 @@ const schema = { module.exports = [ validate(schema), - // Validate and get projectId from the timelineId param, - // and set to request params for checking by the permissions middleware + // Validate and get projectId from the timelineId param, + // and set to request params for checking by the permissions middleware validateTimeline.validateTimelineIdParam, permissions('milestone.edit'), (req, res, next) => { @@ -43,56 +42,56 @@ module.exports = [ let updated; return models.sequelize.transaction(transaction => - // Find the milestone - models.Milestone.findOne({ where }) - .then((milestone) => { - // Not found - if (!milestone) { - const apiErr = new Error(`Milestone not found for milestone id ${req.params.milestoneId}`); - apiErr.status = 404; - return Promise.reject(apiErr); - } + // Find the milestone + models.Milestone.findOne({ where }) + .then((milestone) => { + // Not found + if (!milestone) { + const apiErr = new Error(`Milestone not found for milestone id ${req.params.milestoneId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } - // status already on pause - if (milestone.status !== MILESTONE_STATUS.PAUSED) { - const apiErr = new Error('Milestone status isn\'t paused'); - apiErr.status = 422; - return Promise.reject(apiErr); - } + // status already on pause + if (milestone.status !== MILESTONE_STATUS.PAUSED) { + const apiErr = new Error('Milestone status isn\'t paused'); + apiErr.status = 422; + return Promise.reject(apiErr); + } - original = _.omit(milestone.toJSON(), ['deletedAt', 'deletedBy']); + original = _.omit(milestone.toJSON(), ['deletedAt', 'deletedBy']); - const whereStatus = { referenceId: milestone.id.toString() }; - return models.StatusHistory.findAll({ - whereStatus, - order: [['createdAt', 'desc']], - attributes: ['status'], - limit: 2, - raw: true, - }) + const whereStatus = { referenceId: milestone.id.toString() }; + return models.StatusHistory.findAll({ + whereStatus, + order: [['createdAt', 'desc']], + attributes: ['status'], + limit: 2, + raw: true, + }) .then((statusHistory) => { if (statusHistory.length === 2) { entityToUpdate.status = statusHistory[1].status; entityToUpdate.id = milestone.id; } - // Update + // Update return milestone.update(entityToUpdate, { comment, transaction }); }); - }) + }) .then((updatedMilestone) => { updated = _.omit(updatedMilestone.toJSON(), 'deletedAt', 'deletedBy'); }), - ) + ) .then(() => { // Send event to bus req.log.debug('Sending event to RabbitMQ bus for milestone %d', updated.id); req.app.services.pubsub.publish(BUS_API_EVENT.MILESTONE_TRANSITION_ACTIVE, - { original, updated }, - { correlationId: req.id }, + { original, updated }, + { correlationId: req.id }, ); req.app.emit(BUS_API_EVENT.MILESTONE_TRANSITION_ACTIVE, - { req, original, updated }); + { req, original, updated }); res.json(util.wrapResponse(req.id)); return Promise.resolve(true); diff --git a/src/routes/milestones/status.resume.spec.js b/src/routes/milestones/status.resume.spec.js index 8ff45878..98fc5ca6 100644 --- a/src/routes/milestones/status.resume.spec.js +++ b/src/routes/milestones/status.resume.spec.js @@ -294,112 +294,112 @@ describe('Status resume Milestone', () => { it('should return 403 for member who is not in the project', (done) => { request(server) - .patch('/v4/timelines/1/milestones/1/status/resume') - .set({ - Authorization: `Bearer ${testUtil.jwts.member2}`, - }) - .send(body) - .expect(403, done); + .patch('/v4/timelines/1/milestones/1/status/resume') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send(body) + .expect(403, done); }); it('should return 404 for non-existed timeline', (done) => { request(server) - .patch('/v4/timelines/1234/milestones/1/status/resume') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(404, done); + .patch('/v4/timelines/1234/milestones/1/status/resume') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); }); it('should return 404 for deleted timeline', (done) => { request(server) - .patch('/v4/timelines/3/milestones/1/status/resume') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(404, done); + .patch('/v4/timelines/3/milestones/1/status/resume') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); }); it('should return 404 for non-existed Milestone', (done) => { request(server) - .patch('/v4/timelines/1/milestones/111/status/resume') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(404, done); + .patch('/v4/timelines/1/milestones/111/status/resume') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); }); it('should return 404 for deleted Milestone', (done) => { request(server) - .patch('/v4/timelines/1/milestones/5/status/resume') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(404, done); + .patch('/v4/timelines/1/milestones/5/status/resume') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); }); it('should return 422 for invalid timelineId param', (done) => { request(server) - .patch('/v4/timelines/0/milestones/1/status/resume') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(422, done); + .patch('/v4/timelines/0/milestones/1/status/resume') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); }); it('should return 422 for invalid milestoneId param', (done) => { request(server) - .patch('/v4/timelines/1/milestones/0/status/resume') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(422, done); + .patch('/v4/timelines/1/milestones/0/status/resume') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); }); it('should return 200 and status should update to last status', (done) => { request(server) - .patch('/v4/timelines/1/milestones/1/status/resume') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(body) - .expect(200) - .end(() => { - models.Milestone.findById(1) - .then((milestone) => { - milestone.status.should.be.eql('active'); - done(); - }); - }); + .patch('/v4/timelines/1/milestones/1/status/resume') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end(() => { + models.Milestone.findById(1) + .then((milestone) => { + milestone.status.should.be.eql('active'); + done(); + }); + }); }); it('should have one status history created with multiple sequencial status resumed messages', function fn(done) { this.timeout(10000); request(server) - .patch('/v4/timelines/1/milestones/1/status/resume') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(body) - .expect(200) - .end(() => { - request(server) - .patch('/v4/timelines/1/milestones/1/status/resume') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(body) - .expect(422) - .end(() => { - done(); - }); - }); + .patch('/v4/timelines/1/milestones/1/status/resume') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end(() => { + request(server) + .patch('/v4/timelines/1/milestones/1/status/resume') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(422) + .end(() => { + done(); + }); + }); }); }); }); From a0a12de1beb48c4326acd6784e777c9cdf1ac25b Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Tue, 7 May 2019 20:07:07 +0700 Subject: [PATCH 3/9] send ACTIVATE/PAUSED events to BUS API on milestones pause/resume (with unit tests) --- src/constants.js | 2 + src/events/busApi.js | 28 ++++++++-- src/routes/milestones/status.pause.js | 6 +- src/routes/milestones/status.pause.spec.js | 61 ++++++++++++++++++++- src/routes/milestones/status.resume.js | 6 +- src/routes/milestones/status.resume.spec.js | 60 +++++++++++++++++++- src/routes/milestones/update.spec.js | 2 +- 7 files changed, 147 insertions(+), 18 deletions(-) diff --git a/src/constants.js b/src/constants.js index dae629f7..5a711e00 100644 --- a/src/constants.js +++ b/src/constants.js @@ -74,6 +74,8 @@ export const EVENT = { MILESTONE_ADDED: 'milestone.added', MILESTONE_UPDATED: 'milestone.updated', MILESTONE_REMOVED: 'milestone.removed', + MILESTONE_PAUSED: 'milestone.paused', + MILESTONE_RESUMED: 'milestone.resumed', PROJECT_MEMBER_INVITE_CREATED: 'project.member.invite.created', PROJECT_MEMBER_INVITE_UPDATED: 'project.member.invite.updated', diff --git a/src/events/busApi.js b/src/events/busApi.js index b1b69dc2..68aec333 100644 --- a/src/events/busApi.js +++ b/src/events/busApi.js @@ -529,6 +529,8 @@ module.exports = (app, logger) => { event = BUS_API_EVENT.MILESTONE_TRANSITION_COMPLETED; } else if (updated.status === MILESTONE_STATUS.ACTIVE) { event = BUS_API_EVENT.MILESTONE_TRANSITION_ACTIVE; + } else if (updated.status === MILESTONE_STATUS.PAUSED) { + event = BUS_API_EVENT.MILESTONE_TRANSITION_PAUSED; } if (event) { @@ -592,12 +594,24 @@ module.exports = (app, logger) => { .catch(err => null); // eslint-disable-line no-unused-vars }); - /** + /** * MILESTONE_UPDATED. */ - // eslint-disable-next-line no-unused-vars - app.on(EVENT.ROUTING_KEY.MILESTONE_UPDATED, ({ req, original, updated, cascadedUpdates }) => { - logger.debug(`receive MILESTONE_UPDATED event for milestone ${original.id}`); + + /** + * Handlers for updated milestones which sends events to Kafka + * + * @param {String} eventName event name which causes calling this method + * @param {Object} params params + * @param {Object} params.req request object + * @param {Object} params.original original milestone object + * @param {Object} params.updated updated milestone object + * @param {Object} params.cascadedUpdates milestones updated cascaded + * + * @return {undefined} + */ + function handleMilestoneUpdated(eventName, { req, original, updated, cascadedUpdates }) { + logger.debug(`receive ${eventName} event for milestone ${original.id}`); const projectId = _.parseInt(req.params.projectId); const timeline = _.omit(req.timeline.toJSON(), 'deletedAt', 'deletedBy'); @@ -640,7 +654,11 @@ module.exports = (app, logger) => { } }); }).catch(err => null); // eslint-disable-line no-unused-vars - }); + } + + app.on(EVENT.ROUTING_KEY.MILESTONE_UPDATED, handleMilestoneUpdated.bind(null, EVENT.ROUTING_KEY.MILESTONE_UPDATED)); + app.on(EVENT.ROUTING_KEY.MILESTONE_PAUSED, handleMilestoneUpdated.bind(null, EVENT.ROUTING_KEY.MILESTONE_PAUSED)); + app.on(EVENT.ROUTING_KEY.MILESTONE_RESUMED, handleMilestoneUpdated.bind(null, EVENT.ROUTING_KEY.MILESTONE_RESUMED)); /** * MILESTONE_REMOVED. diff --git a/src/routes/milestones/status.pause.js b/src/routes/milestones/status.pause.js index 3fac5b01..5a00e221 100644 --- a/src/routes/milestones/status.pause.js +++ b/src/routes/milestones/status.pause.js @@ -4,7 +4,7 @@ import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; import validateTimeline from '../../middlewares/validateTimeline'; -import { MILESTONE_STATUS, BUS_API_EVENT } from '../../constants'; +import { MILESTONE_STATUS, EVENT } from '../../constants'; import models from '../../models'; const permissions = tcMiddleware.permissions; @@ -74,12 +74,12 @@ module.exports = [ .then(() => { // Send event to bus req.log.debug('Sending event to RabbitMQ bus for milestone %d', updated.id); - req.app.services.pubsub.publish(BUS_API_EVENT.MILESTONE_TRANSITION_PAUSED, + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.MILESTONE_PAUSED, { original, updated }, { correlationId: req.id }, ); - req.app.emit(BUS_API_EVENT.MILESTONE_TRANSITION_PAUSED, + req.app.emit(EVENT.ROUTING_KEY.MILESTONE_PAUSED, { req, original, updated }); res.json(util.wrapResponse(req.id)); diff --git a/src/routes/milestones/status.pause.spec.js b/src/routes/milestones/status.pause.spec.js index ade09563..1de74830 100644 --- a/src/routes/milestones/status.pause.spec.js +++ b/src/routes/milestones/status.pause.spec.js @@ -5,13 +5,16 @@ import chai from 'chai'; import _ from 'lodash'; import request from 'supertest'; +import sinon from 'sinon'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; +import busApi from '../../services/busApi'; +import { BUS_API_EVENT } from '../../constants'; chai.should(); -describe('Status Pause Milestone', () => { +describe('PAUSE Milestone', () => { beforeEach((done) => { testUtil.clearDb() .then(() => { @@ -254,6 +257,7 @@ describe('Status Pause Milestone', () => { }); after(testUtil.clearDb); + describe('PATCH /timelines/{timelineId}/milestones/{milestoneId}/status/pause', () => { const body = { param: { @@ -368,8 +372,7 @@ describe('Status Pause Milestone', () => { }); }); - it('should have one status history created with multiple sequencial status paused messages', function fn(done) { - this.timeout(10000); + it('should have one status history created with multiple sequential status paused messages', (done) => { request(server) .patch('/v4/timelines/1/milestones/1/status/pause') .set({ @@ -390,5 +393,57 @@ describe('Status Pause Milestone', () => { }); }); }); + + describe('Bus api', () => { + let createEventSpy; + const sandbox = sinon.sandbox.create(); + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should send messages MILESTONE_UPDATED and MILESTONE_TRANSITION_PAUSED', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1/status/pause') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledTwice.should.be.true; + createEventSpy.firstCall.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + createEventSpy.secondCall.calledWith(BUS_API_EVENT.MILESTONE_TRANSITION_PAUSED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/milestones/status.resume.js b/src/routes/milestones/status.resume.js index f75d04a4..f31db46a 100644 --- a/src/routes/milestones/status.resume.js +++ b/src/routes/milestones/status.resume.js @@ -4,7 +4,7 @@ import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; import validateTimeline from '../../middlewares/validateTimeline'; -import { MILESTONE_STATUS, BUS_API_EVENT } from '../../constants'; +import { MILESTONE_STATUS, EVENT } from '../../constants'; import models from '../../models'; const permissions = tcMiddleware.permissions; @@ -85,12 +85,12 @@ module.exports = [ .then(() => { // Send event to bus req.log.debug('Sending event to RabbitMQ bus for milestone %d', updated.id); - req.app.services.pubsub.publish(BUS_API_EVENT.MILESTONE_TRANSITION_ACTIVE, + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.MILESTONE_RESUMED, { original, updated }, { correlationId: req.id }, ); - req.app.emit(BUS_API_EVENT.MILESTONE_TRANSITION_ACTIVE, + req.app.emit(EVENT.ROUTING_KEY.MILESTONE_RESUMED, { req, original, updated }); res.json(util.wrapResponse(req.id)); diff --git a/src/routes/milestones/status.resume.spec.js b/src/routes/milestones/status.resume.spec.js index 98fc5ca6..01d15437 100644 --- a/src/routes/milestones/status.resume.spec.js +++ b/src/routes/milestones/status.resume.spec.js @@ -4,13 +4,16 @@ */ import chai from 'chai'; import request from 'supertest'; +import sinon from 'sinon'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; +import busApi from '../../services/busApi'; +import { BUS_API_EVENT } from '../../constants'; chai.should(); -describe('Status resume Milestone', () => { +describe('RESUME Milestone', () => { beforeEach((done) => { testUtil.clearDb() .then(() => { @@ -379,8 +382,7 @@ describe('Status resume Milestone', () => { }); }); - it('should have one status history created with multiple sequencial status resumed messages', function fn(done) { - this.timeout(10000); + it('should have one status history created with multiple sequential status resumed messages', (done) => { request(server) .patch('/v4/timelines/1/milestones/1/status/resume') .set({ @@ -401,5 +403,57 @@ describe('Status resume Milestone', () => { }); }); }); + + describe('Bus api', () => { + let createEventSpy; + const sandbox = sinon.sandbox.create(); + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should send messages MILESTONE_UPDATED and MILESTONE_TRANSITION_RESUMED', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1/status/resume') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledTwice.should.be.true; + createEventSpy.firstCall.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + createEventSpy.secondCall.calledWith(BUS_API_EVENT.MILESTONE_TRANSITION_ACTIVE, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/milestones/update.spec.js b/src/routes/milestones/update.spec.js index 6bcfa51b..c2bf14a2 100644 --- a/src/routes/milestones/update.spec.js +++ b/src/routes/milestones/update.spec.js @@ -166,7 +166,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 2', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'draft', + status: 'reviewed', type: 'type2', order: 2, plannedText: 'plannedText 2', From 9dba90645c6fb638b760975b93f0442606ecc536 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Thu, 30 May 2019 13:58:27 +0800 Subject: [PATCH 4/9] winning submission from the challenge 30091957 - Topcoder Project Service - Milestones pause/resume refactor --- config/default.json | 3 +- postman.json | 276 +++--------- src/constants.js | 2 - src/events/busApi.js | 2 - src/routes/index.js | 6 - src/routes/milestones/status.pause.js | 90 ---- src/routes/milestones/status.pause.spec.js | 449 ------------------- src/routes/milestones/status.resume.js | 101 ----- src/routes/milestones/status.resume.spec.js | 459 -------------------- src/routes/milestones/update.js | 35 +- src/routes/milestones/update.spec.js | 158 +++++++ swagger.yaml | 134 +----- 12 files changed, 264 insertions(+), 1451 deletions(-) delete mode 100644 src/routes/milestones/status.pause.js delete mode 100644 src/routes/milestones/status.pause.spec.js delete mode 100644 src/routes/milestones/status.resume.js delete mode 100644 src/routes/milestones/status.resume.spec.js diff --git a/config/default.json b/config/default.json index 95f5666d..af7150f0 100644 --- a/config/default.json +++ b/config/default.json @@ -59,5 +59,6 @@ "inviteEmailSectionTitle": "Project Invitation", "connectUrl":"https://connect.topcoder-dev.com", "accountsAppUrl": "https://accounts.topcoder-dev.com", - "MAX_REVISION_NUMBER": 100 + "MAX_REVISION_NUMBER": 100, + "VALID_STATUSES_BEFORE_PAUSED": "[\"active\"]" } diff --git a/postman.json b/postman.json index 6d1a589d..e2c598d6 100644 --- a/postman.json +++ b/postman.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "d9ea7b0f-1d2c-4d48-a693-fe7b51b1e2ea", + "_postman_id": "32160a15-9405-4b42-95ff-26a0d0e58c0d", "name": "tc-project-service", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -118,10 +118,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/2", "host": [ @@ -242,10 +238,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects", "host": [ @@ -958,10 +950,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/7", "host": [ @@ -987,10 +975,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1?fields=id,name,description,members.id,members.projectId", "host": [ @@ -1022,10 +1006,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects", "host": [ @@ -1050,10 +1030,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects?limit=1&offset=1", "host": [ @@ -1088,10 +1064,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects?filter=type%3Dgeneric", "host": [ @@ -1122,10 +1094,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects?sort=type%20desc", "host": [ @@ -1156,10 +1124,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects?fields=id,name,description", "host": [ @@ -1190,10 +1154,6 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects", "host": [ @@ -1781,10 +1741,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "https://api.topcoder-dev.com/v3/direct/projects", "protocol": "https", @@ -2139,10 +2095,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases", "host": [ @@ -2172,10 +2124,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases?fields=status,name,budget", "host": [ @@ -2211,10 +2159,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases?sort=status desc", "host": [ @@ -2250,10 +2194,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases?sort=order desc", "host": [ @@ -2289,10 +2229,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases/1", "host": [ @@ -2461,10 +2397,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases/1/products", "host": [ @@ -2492,10 +2424,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases/1/products/1", "host": [ @@ -2766,10 +2694,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/projectTemplates", "host": [ @@ -2799,10 +2723,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/projectTemplates/1", "host": [ @@ -3211,10 +3131,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/productTemplates", "host": [ @@ -3244,10 +3160,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/productTemplates/3", "host": [ @@ -3495,10 +3407,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/projectTypes", "host": [ @@ -3528,10 +3436,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/projectTypes/generic", "host": [ @@ -3668,10 +3572,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/productCategories", "host": [ @@ -3701,10 +3601,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/productCategories/generic", "host": [ @@ -4071,10 +3967,6 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/timelines?filter=reference%3Dphase%26referenceId%3D1", "host": [ @@ -4108,10 +4000,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/timelines/1", "host": [ @@ -4411,10 +4299,6 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/timelines/1/milestones", "host": [ @@ -4444,10 +4328,6 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/timelines/1/milestones?sort=order desc", "host": [ @@ -4483,9 +4363,39 @@ "value": "Bearer {{jwt-token}}" } ], + "url": { + "raw": "{{api-url}}/v4/timelines/1/milestones/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1", + "milestones", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], "body": { "mode": "raw", - "raw": "" + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"completionDate\": \"2018-09-28T00:00:00.000Z\",\r\n \"status\": \"closed\",\r\n \"type\": \"type2\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 4\r\n ]\r\n },\r\n \"order\": 1,\r\n \"plannedText\": \"plannedText 1-updated\",\r\n \"activeText\": \"activeText 1-updated\",\r\n \"completedText\": \"completedText 1-updated\",\r\n \"blockedText\": \"blockedText 1-updated\"\r\n }\r\n}" }, "url": { "raw": "{{api-url}}/v4/timelines/1/milestones/1", @@ -4504,7 +4414,7 @@ "response": [] }, { - "name": "Update milestone", + "name": "Update milestone - paused", "request": { "method": "PATCH", "header": [ @@ -4519,7 +4429,41 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"completionDate\": \"2018-09-28T00:00:00.000Z\",\r\n \"status\": \"closed\",\r\n \"type\": \"type2\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 4\r\n ]\r\n },\r\n \"order\": 1,\r\n \"plannedText\": \"plannedText 1-updated\",\r\n \"activeText\": \"activeText 1-updated\",\r\n \"completedText\": \"completedText 1-updated\",\r\n \"blockedText\": \"blockedText 1-updated\"\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"status\": \"paused\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 4\r\n ]\r\n },\r\n \"statusComment\": \"milestone paused\"\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines/1/milestones/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1", + "milestones", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone - resume", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"status\": \"resume\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 4\r\n ]\r\n },\r\n \"statusComment\": \"milestone resume\"\r\n }\r\n}" }, "url": { "raw": "{{api-url}}/v4/timelines/1/milestones/1", @@ -4995,10 +4939,6 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates", "host": [ @@ -5028,10 +4968,6 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates?filter=reference%3DproductTemplate%26referenceId%3D1", "host": [ @@ -5067,10 +5003,6 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates?filter=reference%3DproductTemplate%26referenceId%3D1&sort=order desc", "host": [ @@ -5110,10 +5042,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates/1", "host": [ @@ -5394,10 +5322,6 @@ "type": "text" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata", "host": [ @@ -5427,10 +5351,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata?includeAllReferred=true", "host": [ @@ -5471,10 +5391,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/form/dev/versions", "host": [ @@ -5507,10 +5423,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/form/dev/versions/1", "host": [ @@ -5544,10 +5456,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/form/dev", "host": [ @@ -5715,10 +5623,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/form/dev/versions/1/revisions", "host": [ @@ -5753,10 +5657,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/form/dev/versions/1/revisions/1", "host": [ @@ -5933,10 +5833,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/priceConfig/dev/versions", "host": [ @@ -5969,10 +5865,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/priceConfig/dev/versions/1", "host": [ @@ -6006,10 +5898,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/priceConfig/dev", "host": [ @@ -6199,10 +6087,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/priceConfig/dev/versions/3/revisions", "host": [ @@ -6237,10 +6121,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/priceConfig/dev/versions/1/revisions/1", "host": [ @@ -6417,10 +6297,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/planConfig/dev/versions", "host": [ @@ -6453,10 +6329,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/planConfig/dev/versions/3", "host": [ @@ -6490,10 +6362,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/planConfig/dev", "host": [ @@ -6661,10 +6529,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/planConfig/dev/versions/1/revisions", "host": [ @@ -6699,10 +6563,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/planConfig/dev/versions/1/revisions/1", "host": [ diff --git a/src/constants.js b/src/constants.js index 5a711e00..dae629f7 100644 --- a/src/constants.js +++ b/src/constants.js @@ -74,8 +74,6 @@ export const EVENT = { MILESTONE_ADDED: 'milestone.added', MILESTONE_UPDATED: 'milestone.updated', MILESTONE_REMOVED: 'milestone.removed', - MILESTONE_PAUSED: 'milestone.paused', - MILESTONE_RESUMED: 'milestone.resumed', PROJECT_MEMBER_INVITE_CREATED: 'project.member.invite.created', PROJECT_MEMBER_INVITE_UPDATED: 'project.member.invite.updated', diff --git a/src/events/busApi.js b/src/events/busApi.js index 68aec333..ec503afe 100644 --- a/src/events/busApi.js +++ b/src/events/busApi.js @@ -657,8 +657,6 @@ module.exports = (app, logger) => { } app.on(EVENT.ROUTING_KEY.MILESTONE_UPDATED, handleMilestoneUpdated.bind(null, EVENT.ROUTING_KEY.MILESTONE_UPDATED)); - app.on(EVENT.ROUTING_KEY.MILESTONE_PAUSED, handleMilestoneUpdated.bind(null, EVENT.ROUTING_KEY.MILESTONE_PAUSED)); - app.on(EVENT.ROUTING_KEY.MILESTONE_RESUMED, handleMilestoneUpdated.bind(null, EVENT.ROUTING_KEY.MILESTONE_RESUMED)); /** * MILESTONE_REMOVED. diff --git a/src/routes/index.js b/src/routes/index.js index 6faa8ef3..4e8176af 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -176,12 +176,6 @@ router.route('/v4/timelines/:timelineId(\\d+)/milestones/:milestoneId(\\d+)') .patch(require('./milestones/update')) .delete(require('./milestones/delete')); -router.route('/v4/timelines/:timelineId(\\d+)/milestones/:milestoneId(\\d+)/status/pause') - .patch(require('./milestones/status.pause')); - -router.route('/v4/timelines/:timelineId(\\d+)/milestones/:milestoneId(\\d+)/status/resume') - .patch(require('./milestones/status.resume')); - router.route('/v4/timelines/metadata/milestoneTemplates') .post(require('./milestoneTemplates/create')) .get(require('./milestoneTemplates/list')); diff --git a/src/routes/milestones/status.pause.js b/src/routes/milestones/status.pause.js deleted file mode 100644 index 5a00e221..00000000 --- a/src/routes/milestones/status.pause.js +++ /dev/null @@ -1,90 +0,0 @@ -import validate from 'express-validation'; -import _ from 'lodash'; -import Joi from 'joi'; -import { middleware as tcMiddleware } from 'tc-core-library-js'; -import util from '../../util'; -import validateTimeline from '../../middlewares/validateTimeline'; -import { MILESTONE_STATUS, EVENT } from '../../constants'; -import models from '../../models'; - -const permissions = tcMiddleware.permissions; - -const schema = { - params: { - timelineId: Joi.number().integer().positive().required(), - milestoneId: Joi.number().integer().positive().required(), - }, - body: { - param: Joi.object().keys({ - comment: Joi.string().max(512).required(), - }).required(), - }, -}; - -module.exports = [ - validate(schema), - // Validate and get projectId from the timelineId param, - // and set to request params for checking by the permissions middleware - validateTimeline.validateTimelineIdParam, - permissions('milestone.edit'), - (req, res, next) => { - const where = { - timelineId: req.params.timelineId, - id: req.params.milestoneId, - }; - - const entityToUpdate = { - updatedBy: req.authUser.userId, - }; - const comment = req.body.param.comment; - - let original; - let updated; - - return models.sequelize.transaction(transaction => - // Find the milestone - models.Milestone.findOne({ where }) - .then((milestone) => { - // Not found - if (!milestone) { - const apiErr = new Error(`Milestone not found for milestone id ${req.params.milestoneId}`); - apiErr.status = 404; - return Promise.reject(apiErr); - } - - // status already on pause - if (milestone.status === MILESTONE_STATUS.PAUSED) { - const apiErr = new Error('Milestone status already paused'); - apiErr.status = 422; - return Promise.reject(apiErr); - } - - original = _.omit(milestone.toJSON(), ['deletedAt', 'deletedBy']); - - entityToUpdate.status = MILESTONE_STATUS.PAUSED; - entityToUpdate.id = milestone.id; - - // Update - return milestone.update(entityToUpdate, { comment, transaction }); - }) - .then((updatedMilestone) => { - updated = _.omit(updatedMilestone.toJSON(), 'deletedAt', 'deletedBy'); - }), - ) - .then(() => { - // Send event to bus - req.log.debug('Sending event to RabbitMQ bus for milestone %d', updated.id); - req.app.services.pubsub.publish(EVENT.ROUTING_KEY.MILESTONE_PAUSED, - { original, updated }, - { correlationId: req.id }, - ); - - req.app.emit(EVENT.ROUTING_KEY.MILESTONE_PAUSED, - { req, original, updated }); - - res.json(util.wrapResponse(req.id)); - return Promise.resolve(true); - }) - .catch(next); - }, -]; diff --git a/src/routes/milestones/status.pause.spec.js b/src/routes/milestones/status.pause.spec.js deleted file mode 100644 index 1de74830..00000000 --- a/src/routes/milestones/status.pause.spec.js +++ /dev/null @@ -1,449 +0,0 @@ -/* eslint-disable no-unused-expressions */ -/** - * Tests for status.pause.js - */ -import chai from 'chai'; -import _ from 'lodash'; -import request from 'supertest'; -import sinon from 'sinon'; -import models from '../../models'; -import server from '../../app'; -import testUtil from '../../tests/util'; -import busApi from '../../services/busApi'; -import { BUS_API_EVENT } from '../../constants'; - -chai.should(); - -describe('PAUSE Milestone', () => { - beforeEach((done) => { - testUtil.clearDb() - .then(() => { - models.Project.bulkCreate([ - { - type: 'generic', - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'draft', - details: {}, - createdBy: 1, - updatedBy: 1, - lastActivityAt: 1, - lastActivityUserId: '1', - }, - { - type: 'generic', - billingAccountId: 2, - name: 'test2', - description: 'test project2', - status: 'draft', - details: {}, - createdBy: 2, - updatedBy: 2, - lastActivityAt: 1, - lastActivityUserId: '1', - deletedAt: '2018-05-15T00:00:00Z', - }, - ]) - .then(() => { - // Create member - models.ProjectMember.bulkCreate([ - { - userId: 40051332, - projectId: 1, - role: 'copilot', - isPrimary: true, - createdBy: 1, - updatedBy: 1, - }, - { - userId: 40051331, - projectId: 1, - role: 'customer', - isPrimary: true, - createdBy: 1, - updatedBy: 1, - }, - ]).then(() => - // Create phase - models.ProjectPhase.bulkCreate([ - { - projectId: 1, - name: 'test project phase 1', - 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 2', - }, - createdBy: 1, - updatedBy: 1, - }, - { - projectId: 2, - name: 'test project phase 2', - status: 'active', - startDate: '2018-05-16T00:00:00Z', - endDate: '2018-05-16T12:00:00Z', - budget: 21.0, - progress: 1.234567, - details: { - message: 'This can be any json 2', - }, - createdBy: 2, - updatedBy: 2, - deletedAt: '2018-05-15T00:00:00Z', - }, - ])) - .then(() => - // Create timelines - models.Timeline.bulkCreate([ - { - name: 'name 1', - description: 'description 1', - startDate: '2018-05-02T00:00:00.000Z', - endDate: '2018-06-12T00:00:00.000Z', - reference: 'project', - referenceId: 1, - createdBy: 1, - updatedBy: 1, - }, - { - name: 'name 2', - description: 'description 2', - startDate: '2018-05-12T00:00:00.000Z', - endDate: '2018-06-13T00:00:00.000Z', - reference: 'phase', - referenceId: 1, - createdBy: 1, - updatedBy: 1, - }, - { - name: 'name 3', - description: 'description 3', - startDate: '2018-05-13T00:00:00.000Z', - endDate: '2018-06-14T00:00:00.000Z', - reference: 'phase', - referenceId: 1, - createdBy: 1, - updatedBy: 1, - deletedAt: '2018-05-14T00:00:00.000Z', - }, - ]).then(() => models.Milestone.bulkCreate([ - { - id: 1, - timelineId: 1, - name: 'Milestone 1', - duration: 2, - startDate: '2018-05-13T00:00:00.000Z', - endDate: '2018-05-14T00:00:00.000Z', - completionDate: '2018-05-15T00:00:00.000Z', - status: 'draft', - type: 'type1', - details: { - detail1: { - subDetail1A: 1, - subDetail1B: 2, - }, - detail2: [1, 2, 3], - }, - order: 1, - plannedText: 'plannedText 1', - activeText: 'activeText 1', - completedText: 'completedText 1', - blockedText: 'blockedText 1', - createdBy: 1, - updatedBy: 2, - createdAt: '2018-05-11T00:00:00.000Z', - updatedAt: '2018-05-11T00:00:00.000Z', - }, - { - id: 2, - timelineId: 1, - name: 'Milestone 2', - duration: 3, - startDate: '2018-05-14T00:00:00.000Z', - status: 'open', - type: 'type2', - order: 2, - plannedText: 'plannedText 2', - activeText: 'activeText 2', - completedText: 'completedText 2', - blockedText: 'blockedText 2', - createdBy: 2, - updatedBy: 3, - createdAt: '2018-05-11T00:00:00.000Z', - updatedAt: '2018-05-11T00:00:00.000Z', - }, - { - id: 3, - timelineId: 1, - name: 'Milestone 3', - duration: 3, - startDate: '2018-05-14T00:00:00.000Z', - status: 'open', - type: 'type3', - order: 3, - plannedText: 'plannedText 3', - activeText: 'activeText 3', - completedText: 'completedText 3', - blockedText: 'blockedText 3', - createdBy: 2, - updatedBy: 3, - createdAt: '2018-05-11T00:00:00.000Z', - updatedAt: '2018-05-11T00:00:00.000Z', - }, - { - id: 4, - timelineId: 1, - name: 'Milestone 4', - duration: 3, - startDate: '2018-05-14T00:00:00.000Z', - status: 'open', - type: 'type4', - order: 4, - plannedText: 'plannedText 4', - activeText: 'activeText 4', - completedText: 'completedText 4', - blockedText: 'blockedText 4', - createdBy: 2, - updatedBy: 3, - createdAt: '2018-05-11T00:00:00.000Z', - updatedAt: '2018-05-11T00:00:00.000Z', - }, - { - id: 5, - timelineId: 1, - name: 'Milestone 5', - duration: 3, - startDate: '2018-05-14T00:00:00.000Z', - status: 'open', - type: 'type5', - order: 5, - plannedText: 'plannedText 5', - activeText: 'activeText 5', - completedText: 'completedText 5', - blockedText: 'blockedText 5', - createdBy: 2, - updatedBy: 3, - createdAt: '2018-05-11T00:00:00.000Z', - updatedAt: '2018-05-11T00:00:00.000Z', - deletedAt: '2018-05-11T00:00:00.000Z', - }, - { - id: 6, - timelineId: 2, // Timeline 2 - name: 'Milestone 6', - duration: 3, - startDate: '2018-05-14T00:00:00.000Z', - status: 'open', - type: 'type5', - order: 1, - plannedText: 'plannedText 6', - activeText: 'activeText 6', - completedText: 'completedText 6', - blockedText: 'blockedText 6', - createdBy: 2, - updatedBy: 3, - createdAt: '2018-05-11T00:00:00.000Z', - updatedAt: '2018-05-11T00:00:00.000Z', - }, - ]))) - .then(() => done()); - }); - }); - }); - - after(testUtil.clearDb); - - describe('PATCH /timelines/{timelineId}/milestones/{milestoneId}/status/pause', () => { - const body = { - param: { - comment: 'comment', - }, - }; - - it('should return 403 if user is not authenticated', (done) => { - request(server) - .patch('/v4/timelines/1/milestones/1/status/pause') - .send(body) - .expect(403, done); - }); - - - it('should return 403 for member who is not in the project', (done) => { - request(server) - .patch('/v4/timelines/1/milestones/1/status/pause') - .set({ - Authorization: `Bearer ${testUtil.jwts.member2}`, - }) - .send(body) - .expect(403, done); - }); - - it('should return 404 for non-existed timeline', (done) => { - request(server) - .patch('/v4/timelines/1234/milestones/1/status/pause') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(404, done); - }); - - it('should return 404 for deleted timeline', (done) => { - request(server) - .patch('/v4/timelines/3/milestones/1/status/pause') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(404, done); - }); - - it('should return 404 for non-existed Milestone', (done) => { - request(server) - .patch('/v4/timelines/1/milestones/111/status/pause') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(404, done); - }); - - it('should return 404 for deleted Milestone', (done) => { - request(server) - .patch('/v4/timelines/1/milestones/5/status/pause') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(404, done); - }); - - it('should return 422 for invalid timelineId param', (done) => { - request(server) - .patch('/v4/timelines/0/milestones/1/status/pause') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(422, done); - }); - - it('should return 422 for invalid milestoneId param', (done) => { - request(server) - .patch('/v4/timelines/1/milestones/0/status/pause') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(422, done); - }); - - it('should return 422 for missing comment', (done) => { - const partialBody = _.cloneDeep(body); - delete partialBody.param.comment; - request(server) - .patch('/v4/timelines/1/milestones/1/status/pause') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(partialBody) - .expect(422, done); - }); - - it('should return 200 and status should update to paused', (done) => { - request(server) - .patch('/v4/timelines/1/milestones/1/status/pause') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(body) - .expect(200) - .end(() => { - models.Milestone.findById(1) - .then((milestone) => { - milestone.status.should.be.eql('paused'); - done(); - }); - }); - }); - - it('should have one status history created with multiple sequential status paused messages', (done) => { - request(server) - .patch('/v4/timelines/1/milestones/1/status/pause') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(body) - .expect(200) - .end(() => { - request(server) - .patch('/v4/timelines/1/milestones/1/status/pause') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(body) - .expect(422) - .end(() => { - done(); - }); - }); - }); - - describe('Bus api', () => { - let createEventSpy; - const sandbox = sinon.sandbox.create(); - - before((done) => { - // Wait for 500ms in order to wait for createEvent calls from previous tests to complete - testUtil.wait(done); - }); - - beforeEach(() => { - createEventSpy = sandbox.spy(busApi, 'createEvent'); - }); - - afterEach(() => { - sandbox.restore(); - }); - - it('should send messages MILESTONE_UPDATED and MILESTONE_TRANSITION_PAUSED', (done) => { - request(server) - .patch('/v4/timelines/1/milestones/1/status/pause') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(body) - .expect(200) - .end((err) => { - if (err) { - done(err); - } else { - testUtil.wait(() => { - createEventSpy.calledTwice.should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, sinon.match({ - projectId: 1, - projectName: 'test1', - projectUrl: 'https://local.topcoder-dev.com/projects/1', - userId: 40051333, - initiatorUserId: 40051333, - })).should.be.true; - createEventSpy.secondCall.calledWith(BUS_API_EVENT.MILESTONE_TRANSITION_PAUSED, sinon.match({ - projectId: 1, - projectName: 'test1', - projectUrl: 'https://local.topcoder-dev.com/projects/1', - userId: 40051333, - initiatorUserId: 40051333, - })).should.be.true; - done(); - }); - } - }); - }); - }); - }); -}); diff --git a/src/routes/milestones/status.resume.js b/src/routes/milestones/status.resume.js deleted file mode 100644 index f31db46a..00000000 --- a/src/routes/milestones/status.resume.js +++ /dev/null @@ -1,101 +0,0 @@ -import validate from 'express-validation'; -import _ from 'lodash'; -import Joi from 'joi'; -import { middleware as tcMiddleware } from 'tc-core-library-js'; -import util from '../../util'; -import validateTimeline from '../../middlewares/validateTimeline'; -import { MILESTONE_STATUS, EVENT } from '../../constants'; -import models from '../../models'; - -const permissions = tcMiddleware.permissions; - -const schema = { - params: { - timelineId: Joi.number().integer().positive().required(), - milestoneId: Joi.number().integer().positive().required(), - }, - body: { - param: Joi.object().keys({ - comment: Joi.string().max(512).optional(), - }), - }, -}; - -module.exports = [ - validate(schema), - // Validate and get projectId from the timelineId param, - // and set to request params for checking by the permissions middleware - validateTimeline.validateTimelineIdParam, - permissions('milestone.edit'), - (req, res, next) => { - const where = { - timelineId: req.params.timelineId, - id: req.params.milestoneId, - }; - - const entityToUpdate = { - updatedBy: req.authUser.userId, - }; - - const comment = req.body.param ? req.body.param.comment : ''; - let original; - let updated; - - return models.sequelize.transaction(transaction => - // Find the milestone - models.Milestone.findOne({ where }) - .then((milestone) => { - // Not found - if (!milestone) { - const apiErr = new Error(`Milestone not found for milestone id ${req.params.milestoneId}`); - apiErr.status = 404; - return Promise.reject(apiErr); - } - - // status already on pause - if (milestone.status !== MILESTONE_STATUS.PAUSED) { - const apiErr = new Error('Milestone status isn\'t paused'); - apiErr.status = 422; - return Promise.reject(apiErr); - } - - original = _.omit(milestone.toJSON(), ['deletedAt', 'deletedBy']); - - const whereStatus = { referenceId: milestone.id.toString() }; - return models.StatusHistory.findAll({ - whereStatus, - order: [['createdAt', 'desc']], - attributes: ['status'], - limit: 2, - raw: true, - }) - .then((statusHistory) => { - if (statusHistory.length === 2) { - entityToUpdate.status = statusHistory[1].status; - entityToUpdate.id = milestone.id; - } - // Update - return milestone.update(entityToUpdate, { comment, transaction }); - }); - }) - .then((updatedMilestone) => { - updated = _.omit(updatedMilestone.toJSON(), 'deletedAt', 'deletedBy'); - }), - ) - .then(() => { - // Send event to bus - req.log.debug('Sending event to RabbitMQ bus for milestone %d', updated.id); - req.app.services.pubsub.publish(EVENT.ROUTING_KEY.MILESTONE_RESUMED, - { original, updated }, - { correlationId: req.id }, - ); - - req.app.emit(EVENT.ROUTING_KEY.MILESTONE_RESUMED, - { req, original, updated }); - - res.json(util.wrapResponse(req.id)); - return Promise.resolve(true); - }) - .catch(next); - }, -]; diff --git a/src/routes/milestones/status.resume.spec.js b/src/routes/milestones/status.resume.spec.js deleted file mode 100644 index 01d15437..00000000 --- a/src/routes/milestones/status.resume.spec.js +++ /dev/null @@ -1,459 +0,0 @@ -/* eslint-disable no-unused-expressions */ -/** - * Tests for status.resume.js - */ -import chai from 'chai'; -import request from 'supertest'; -import sinon from 'sinon'; -import models from '../../models'; -import server from '../../app'; -import testUtil from '../../tests/util'; -import busApi from '../../services/busApi'; -import { BUS_API_EVENT } from '../../constants'; - -chai.should(); - -describe('RESUME Milestone', () => { - beforeEach((done) => { - testUtil.clearDb() - .then(() => { - models.Project.bulkCreate([ - { - type: 'generic', - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'draft', - details: {}, - createdBy: 1, - updatedBy: 1, - lastActivityAt: 1, - lastActivityUserId: '1', - }, - { - type: 'generic', - billingAccountId: 2, - name: 'test2', - description: 'test project2', - status: 'draft', - details: {}, - createdBy: 2, - updatedBy: 2, - lastActivityAt: 1, - lastActivityUserId: '1', - deletedAt: '2018-05-15T00:00:00Z', - }, - ]) - .then(() => { - // Create member - models.ProjectMember.bulkCreate([ - { - userId: 40051332, - projectId: 1, - role: 'copilot', - isPrimary: true, - createdBy: 1, - updatedBy: 1, - }, - { - userId: 40051331, - projectId: 1, - role: 'customer', - isPrimary: true, - createdBy: 1, - updatedBy: 1, - }, - ]).then(() => - // Create phase - models.ProjectPhase.bulkCreate([ - { - projectId: 1, - name: 'test project phase 1', - 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 2', - }, - createdBy: 1, - updatedBy: 1, - }, - { - projectId: 2, - name: 'test project phase 2', - status: 'active', - startDate: '2018-05-16T00:00:00Z', - endDate: '2018-05-16T12:00:00Z', - budget: 21.0, - progress: 1.234567, - details: { - message: 'This can be any json 2', - }, - createdBy: 2, - updatedBy: 2, - deletedAt: '2018-05-15T00:00:00Z', - }, - ])) - .then(() => { - models.StatusHistory.bulkCreate([ - { - reference: 'milestone', - referenceId: '1', - status: 'active', - comment: 'comment', - createdBy: 1, - createdAt: '2018-05-15T00:00:00Z', - updatedBy: 1, - updatedAt: '2018-05-15T00:00:00Z', - }, - { - reference: 'milestone', - referenceId: '1', - status: 'paused', - comment: 'comment', - createdBy: 1, - createdAt: '2018-05-16T00:00:00Z', - updatedBy: 1, - updatedAt: '2018-05-16T00:00:00Z', - }, - ]); - }) - .then(() => - // Create timelines - models.Timeline.bulkCreate([ - { - name: 'name 1', - description: 'description 1', - startDate: '2018-05-02T00:00:00.000Z', - endDate: '2018-06-12T00:00:00.000Z', - reference: 'project', - referenceId: 1, - createdBy: 1, - updatedBy: 1, - }, - { - name: 'name 2', - description: 'description 2', - startDate: '2018-05-12T00:00:00.000Z', - endDate: '2018-06-13T00:00:00.000Z', - reference: 'phase', - referenceId: 1, - createdBy: 1, - updatedBy: 1, - }, - { - name: 'name 3', - description: 'description 3', - startDate: '2018-05-13T00:00:00.000Z', - endDate: '2018-06-14T00:00:00.000Z', - reference: 'phase', - referenceId: 1, - createdBy: 1, - updatedBy: 1, - deletedAt: '2018-05-14T00:00:00.000Z', - }, - ]).then(() => models.Milestone.bulkCreate([ - { - id: 1, - timelineId: 1, - name: 'Milestone 1', - duration: 2, - startDate: '2018-05-13T00:00:00.000Z', - endDate: '2018-05-14T00:00:00.000Z', - completionDate: '2018-05-15T00:00:00.000Z', - status: 'paused', - type: 'type1', - details: { - detail1: { - subDetail1A: 1, - subDetail1B: 2, - }, - detail2: [1, 2, 3], - }, - order: 1, - plannedText: 'plannedText 1', - activeText: 'activeText 1', - completedText: 'completedText 1', - blockedText: 'blockedText 1', - createdBy: 1, - updatedBy: 2, - createdAt: '2018-05-11T00:00:00.000Z', - updatedAt: '2018-05-11T00:00:00.000Z', - }, - { - id: 2, - timelineId: 1, - name: 'Milestone 2', - duration: 3, - startDate: '2018-05-14T00:00:00.000Z', - status: 'open', - type: 'type2', - order: 2, - plannedText: 'plannedText 2', - activeText: 'activeText 2', - completedText: 'completedText 2', - blockedText: 'blockedText 2', - createdBy: 2, - updatedBy: 3, - createdAt: '2018-05-11T00:00:00.000Z', - updatedAt: '2018-05-11T00:00:00.000Z', - }, - { - id: 3, - timelineId: 1, - name: 'Milestone 3', - duration: 3, - startDate: '2018-05-14T00:00:00.000Z', - status: 'open', - type: 'type3', - order: 3, - plannedText: 'plannedText 3', - activeText: 'activeText 3', - completedText: 'completedText 3', - blockedText: 'blockedText 3', - createdBy: 2, - updatedBy: 3, - createdAt: '2018-05-11T00:00:00.000Z', - updatedAt: '2018-05-11T00:00:00.000Z', - }, - { - id: 4, - timelineId: 1, - name: 'Milestone 4', - duration: 3, - startDate: '2018-05-14T00:00:00.000Z', - status: 'open', - type: 'type4', - order: 4, - plannedText: 'plannedText 4', - activeText: 'activeText 4', - completedText: 'completedText 4', - blockedText: 'blockedText 4', - createdBy: 2, - updatedBy: 3, - createdAt: '2018-05-11T00:00:00.000Z', - updatedAt: '2018-05-11T00:00:00.000Z', - }, - { - id: 5, - timelineId: 1, - name: 'Milestone 5', - duration: 3, - startDate: '2018-05-14T00:00:00.000Z', - status: 'open', - type: 'type5', - order: 5, - plannedText: 'plannedText 5', - activeText: 'activeText 5', - completedText: 'completedText 5', - blockedText: 'blockedText 5', - createdBy: 2, - updatedBy: 3, - createdAt: '2018-05-11T00:00:00.000Z', - updatedAt: '2018-05-11T00:00:00.000Z', - deletedAt: '2018-05-11T00:00:00.000Z', - }, - { - id: 6, - timelineId: 2, // Timeline 2 - name: 'Milestone 6', - duration: 3, - startDate: '2018-05-14T00:00:00.000Z', - status: 'open', - type: 'type5', - order: 1, - plannedText: 'plannedText 6', - activeText: 'activeText 6', - completedText: 'completedText 6', - blockedText: 'blockedText 6', - createdBy: 2, - updatedBy: 3, - createdAt: '2018-05-11T00:00:00.000Z', - updatedAt: '2018-05-11T00:00:00.000Z', - }, - ]))) - .then(() => done()); - }); - }); - }); - - after(testUtil.clearDb); - describe('PATCH /timelines/{timelineId}/milestones/{milestoneId}/status/resume', () => { - const body = { - param: { - comment: 'comment', - }, - }; - - it('should return 403 if user is not authenticated', (done) => { - request(server) - .patch('/v4/timelines/1/milestones/1/status/resume') - .send(body) - .expect(403, done); - }); - - - it('should return 403 for member who is not in the project', (done) => { - request(server) - .patch('/v4/timelines/1/milestones/1/status/resume') - .set({ - Authorization: `Bearer ${testUtil.jwts.member2}`, - }) - .send(body) - .expect(403, done); - }); - - it('should return 404 for non-existed timeline', (done) => { - request(server) - .patch('/v4/timelines/1234/milestones/1/status/resume') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(404, done); - }); - - it('should return 404 for deleted timeline', (done) => { - request(server) - .patch('/v4/timelines/3/milestones/1/status/resume') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(404, done); - }); - - it('should return 404 for non-existed Milestone', (done) => { - request(server) - .patch('/v4/timelines/1/milestones/111/status/resume') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(404, done); - }); - - it('should return 404 for deleted Milestone', (done) => { - request(server) - .patch('/v4/timelines/1/milestones/5/status/resume') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(404, done); - }); - - it('should return 422 for invalid timelineId param', (done) => { - request(server) - .patch('/v4/timelines/0/milestones/1/status/resume') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(422, done); - }); - - it('should return 422 for invalid milestoneId param', (done) => { - request(server) - .patch('/v4/timelines/1/milestones/0/status/resume') - .send(body) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(422, done); - }); - - it('should return 200 and status should update to last status', (done) => { - request(server) - .patch('/v4/timelines/1/milestones/1/status/resume') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(body) - .expect(200) - .end(() => { - models.Milestone.findById(1) - .then((milestone) => { - milestone.status.should.be.eql('active'); - done(); - }); - }); - }); - - it('should have one status history created with multiple sequential status resumed messages', (done) => { - request(server) - .patch('/v4/timelines/1/milestones/1/status/resume') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(body) - .expect(200) - .end(() => { - request(server) - .patch('/v4/timelines/1/milestones/1/status/resume') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(body) - .expect(422) - .end(() => { - done(); - }); - }); - }); - - describe('Bus api', () => { - let createEventSpy; - const sandbox = sinon.sandbox.create(); - - before((done) => { - // Wait for 500ms in order to wait for createEvent calls from previous tests to complete - testUtil.wait(done); - }); - - beforeEach(() => { - createEventSpy = sandbox.spy(busApi, 'createEvent'); - }); - - afterEach(() => { - sandbox.restore(); - }); - - it('should send messages MILESTONE_UPDATED and MILESTONE_TRANSITION_RESUMED', (done) => { - request(server) - .patch('/v4/timelines/1/milestones/1/status/resume') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(body) - .expect(200) - .end((err) => { - if (err) { - done(err); - } else { - testUtil.wait(() => { - createEventSpy.calledTwice.should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, sinon.match({ - projectId: 1, - projectName: 'test1', - projectUrl: 'https://local.topcoder-dev.com/projects/1', - userId: 40051333, - initiatorUserId: 40051333, - })).should.be.true; - createEventSpy.secondCall.calledWith(BUS_API_EVENT.MILESTONE_TRANSITION_ACTIVE, sinon.match({ - projectId: 1, - projectName: 'test1', - projectUrl: 'https://local.topcoder-dev.com/projects/1', - userId: 40051333, - initiatorUserId: 40051333, - })).should.be.true; - done(); - }); - } - }); - }); - }); - }); -}); diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index 98a5cdf1..59a8a3bf 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -4,6 +4,7 @@ import validate from 'express-validation'; import _ from 'lodash'; import moment from 'moment'; +import config from 'config'; import Joi from 'joi'; import Sequelize from 'sequelize'; import { middleware as tcMiddleware } from 'tc-core-library-js'; @@ -111,6 +112,7 @@ const schema = { completedText: Joi.string().max(512).optional(), blockedText: Joi.string().max(512).optional(), hidden: Joi.boolean().optional(), + statusComment: Joi.string().when('status', { is: 'paused', then: Joi.required(), otherwise: Joi.optional() }), createdAt: Joi.any().strip(), updatedAt: Joi.any().strip(), deletedAt: Joi.any().strip(), @@ -146,13 +148,42 @@ module.exports = [ return models.sequelize.transaction(() => // Find the milestone models.Milestone.findOne({ where }) - .then((milestone) => { + .then(async (milestone) => { // Not found if (!milestone) { const apiErr = new Error(`Milestone not found for milestone id ${req.params.milestoneId}`); apiErr.status = 404; return Promise.reject(apiErr); } + const validStatuses = JSON.parse(config.get('VALID_STATUSES_BEFORE_PAUSED')); + if (entityToUpdate.status === MILESTONE_STATUS.PAUSED && !validStatuses.includes(milestone.status)) { + const validStatutesStr = validStatuses.join(', '); + const apiErr = new Error(`Milestone can only be paused from the next statuses: ${validStatutesStr}`); + apiErr.status = 422; + return Promise.reject(apiErr); + } + + if (entityToUpdate.status === 'resume') { + if (milestone.status !== MILESTONE_STATUS.PAUSED) { + const apiErr = new Error('Milestone status isn\'t paused'); + apiErr.status = 422; + return Promise.reject(apiErr); + } + const statusHistory = await models.StatusHistory.findAll({ + where: { referenceId: milestone.id.toString() }, + order: [['createdAt', 'desc']], + attributes: ['status'], + limit: 2, + raw: true, + }); + if (statusHistory.length === 2) { + entityToUpdate.status = statusHistory[1].status; + } else { + const apiErr = new Error('No previous status is found'); + apiErr.status = 500; + return Promise.reject(apiErr); + } + } if (entityToUpdate.completionDate && entityToUpdate.completionDate < milestone.startDate) { const apiErr = new Error('The milestone completionDate should be greater or equal than the startDate.'); @@ -207,7 +238,7 @@ module.exports = [ } // Update - return milestone.update(entityToUpdate); + return milestone.update(entityToUpdate, { comment: entityToUpdate.statusComment }); }) .then((updatedMilestone) => { // Omit deletedAt, deletedBy diff --git a/src/routes/milestones/update.spec.js b/src/routes/milestones/update.spec.js index c2bf14a2..f38c9c7f 100644 --- a/src/routes/milestones/update.spec.js +++ b/src/routes/milestones/update.spec.js @@ -1085,6 +1085,164 @@ describe('UPDATE Milestone', () => { .end(done); }); + it('should return 422 if try to pause and statusComment is missed', (done) => { + const newBody = _.cloneDeep(body); + newBody.param.status = 'paused'; + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newBody) + .expect(422, done); + }); + + it('should return 422 if try to pause not active milestone', (done) => { + const newBody = _.cloneDeep(body); + newBody.param.status = 'paused'; + newBody.param.statusComment = 'milestone paused'; + request(server) + .patch('/v4/timelines/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newBody) + .expect(422, done); + }); + + it('should return 200 if try to pause and should have one status history created', (done) => { + const newBody = _.cloneDeep(body); + newBody.param.status = 'paused'; + newBody.param.statusComment = 'milestone paused'; + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newBody) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + models.Milestone.findById(1).then((milestone) => { + milestone.status.should.be.eql('paused'); + return models.StatusHistory.findAll({ + where: { + reference: 'milestone', + referenceId: milestone.id.toString(), + status: milestone.status, + comment: 'milestone paused', + }, + paranoid: false, + }).then((statusHistories) => { + statusHistories.length.should.be.eql(1); + done(); + }); + }); + } + }); + }); + + it('should return 422 if try to resume not paused milestone', (done) => { + const newBody = _.cloneDeep(body); + newBody.param.status = 'resume'; + request(server) + .patch('/v4/timelines/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newBody) + .expect(422, done); + }); + + it('should return 200 if try to resume then status should update to last status and ' + + 'should have one status history created', (done) => { + const newBody = _.cloneDeep(body); + newBody.param.status = 'resume'; + newBody.param.statusComment = 'new comment'; + models.Milestone.bulkCreate([ + { + id: 7, + timelineId: 1, + name: 'Milestone 1 [paused]', + duration: 2, + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-14T00:00:00.000Z', + completionDate: '2018-05-15T00:00:00.000Z', + status: 'paused', + type: 'type1', + details: { + detail1: { + subDetail1A: 1, + subDetail1B: 2, + }, + detail2: [1, 2, 3], + }, + order: 1, + plannedText: 'plannedText 1', + activeText: 'activeText 1', + completedText: 'completedText 1', + blockedText: 'blockedText 1', + createdBy: 1, + updatedBy: 2, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + ]).then(() => models.StatusHistory.bulkCreate([ + { + reference: 'milestone', + referenceId: '7', + status: 'active', + comment: 'comment', + createdBy: 1, + createdAt: '2018-05-15T00:00:00Z', + updatedBy: 1, + updatedAt: '2018-05-15T00:00:00Z', + }, + { + reference: 'milestone', + referenceId: '7', + status: 'paused', + comment: 'comment', + createdBy: 1, + createdAt: '2018-05-16T00:00:00Z', + updatedBy: 1, + updatedAt: '2018-05-16T00:00:00Z', + }, + ]).then(() => { + request(server) + .patch('/v4/timelines/1/milestones/7') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newBody) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + models.Milestone.findById(7).then((milestone) => { + milestone.status.should.be.eql('active'); + + return models.StatusHistory.findAll({ + where: { + reference: 'milestone', + referenceId: milestone.id.toString(), + status: 'active', + comment: 'new comment', + }, + paranoid: false, + }).then((statusHistories) => { + statusHistories.length.should.be.eql(1); + done(); + }); + }); + } + }); + })); + }); + describe('Bus api', () => { let createEventSpy; const sandbox = sinon.sandbox.create(); diff --git a/swagger.yaml b/swagger.yaml index 489a2ad0..751ab184 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -1715,86 +1715,6 @@ paths: description: Invalid input schema: $ref: '#/definitions/ErrorModel' - '/timelines/{timelineId}/milestones/{milestoneId}/status/pause': - parameters: - - $ref: '#/parameters/timelineIdParam' - - $ref: '#/parameters/milestoneIdParam' - patch: - tags: - - milestone - operationId: pauseMilestone - security: - - Bearer: [] - description: >- - Update a milestone Status to Paused. All users who can edit the timeline can access this - endpoint. If the status is alread paused it doesnt allow. - responses: - '200': - description: Successfully updated status. - schema: - $ref: '#/definitions/StatusPauseResumeResponse' - '403': - description: No permission or wrong token - schema: - $ref: '#/definitions/ErrorModel' - '404': - description: Not found - schema: - $ref: '#/definitions/ErrorModel' - '422': - description: Invalid input or invalid previous status - schema: - $ref: '#/definitions/ErrorModel' - default: - description: error payload - schema: - $ref: '#/definitions/ErrorModel' - parameters: - - name: body - in: body - required: true - schema: - $ref: '#/definitions/MilestonePatchPauseStatus' - '/timelines/{timelineId}/milestones/{milestoneId}/status/resume': - parameters: - - $ref: '#/parameters/timelineIdParam' - - $ref: '#/parameters/milestoneIdParam' - patch: - tags: - - milestone - operationId: resumeMilestone - security: - - Bearer: [] - description: >- - Update a milestone Status to Active. All users who can edit the timeline can access this - endpoint. If the status is different than paused it doesnt allow. - responses: - '200': - description: Successfully updated status. - schema: - $ref: '#/definitions/StatusPauseResumeResponse' - '403': - description: No permission or wrong token - schema: - $ref: '#/definitions/ErrorModel' - '404': - description: Not found - schema: - $ref: '#/definitions/ErrorModel' - '422': - description: Invalid input or invalid previous status - schema: - $ref: '#/definitions/ErrorModel' - default: - description: error payload - schema: - $ref: '#/definitions/ErrorModel' - parameters: - - name: body - in: body - required: false - schema: - $ref: '#/definitions/MilestonePatchResumeStatus' /timelines/metadata/milestoneTemplates: get: tags: @@ -4712,22 +4632,6 @@ definitions: blockedText: type: string description: the milestone blocked text - MilestonePauseStatusRequest: - title: Milestone Pause Status object - type: object - required: - - comment - properties: - comment: - type: string - description: the status comment - MilestoneResumeStatusRequest: - title: Milestone Resume Status object - type: object - properties: - comment: - type: string - description: the status comment MilestonePatchRequest: title: Milestone request object type: object @@ -4781,6 +4685,9 @@ definitions: blockedText: type: string description: the milestone blocked text + statusComment: + type: string + description: the milestone status history comment MilestonePostBodyParam: title: Milestone body param type: object @@ -4797,22 +4704,6 @@ definitions: properties: param: $ref: '#/definitions/MilestonePatchRequest' - MilestonePatchPauseStatus: - title: Milestone Pause status body param - type: object - required: - - param - properties: - param: - $ref: '#/definitions/MilestonePauseStatusRequest' - MilestonePatchResumeStatus: - title: Milestone REsume status body param - type: object - required: - - param - properties: - param: - $ref: '#/definitions/MilestoneResumeStatusRequest' Milestone: title: Milestone object allOf: @@ -4868,25 +4759,6 @@ definitions: $ref: '#/definitions/ResponseMetadata' content: $ref: '#/definitions/Milestone' - StatusPauseResumeResponse: - title: Milestone status pause and resume object - type: object - properties: - id: - type: string - description: unique id identifying the request - version: - type: string - result: - type: object - properties: - success: - type: boolean - status: - type: string - description: http status code - metadata: - $ref: '#/definitions/ResponseMetadata' MilestoneListResponse: title: Milestone list response object type: object From be9dd0e2250a98dfeb4c2062d1cbdc860be46e64 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Tue, 25 Jun 2019 13:27:30 +0800 Subject: [PATCH 5/9] winning submission from challenge 30094113 - Topcoder Project Service - Milestone status history --- src/models/milestone.js | 37 ++++++++++++++++++-- src/routes/milestones/create.spec.js | 8 +++++ src/routes/milestones/get.spec.js | 36 +++++++++++++++++++- src/routes/milestones/list.spec.js | 37 ++++++++++++++++++-- src/routes/milestones/update.spec.js | 30 +++++++++++++++++ src/routes/timelines/create.spec.js | 8 +++++ src/routes/timelines/get.spec.js | 33 ++++++++++++++++++ src/routes/timelines/list.spec.js | 33 ++++++++++++++++++ src/routes/timelines/update.spec.js | 35 +++++++++++++++++++ swagger.yaml | 50 ++++++++++++++++++++++++++-- 10 files changed, 298 insertions(+), 9 deletions(-) diff --git a/src/models/milestone.js b/src/models/milestone.js index 389a66e5..746a437e 100644 --- a/src/models/milestone.js +++ b/src/models/milestone.js @@ -1,7 +1,29 @@ +import _ from 'lodash'; import moment from 'moment'; import models from '../models'; /* eslint-disable valid-jsdoc */ +/** + * Populate and map milestone model with statusHistory + * @param {Object} milestone the milestone + * @returns {Promise} promise + */ +const mapWithStatusHistory = async (milestone) => { + try { + const statusHistory = await models.StatusHistory.findAll({ + where: { + referenceId: milestone.id.toString(), + reference: 'milestone', + }, + order: [['createdAt', 'desc']], + raw: true, + }); + return _.merge(milestone, { dataValues: { statusHistory } }); + } catch (err) { + return _.merge(milestone, { dataValues: { statusHistory: [] } }); + } +}; + /** * The Milestone model */ @@ -93,7 +115,7 @@ module.exports = (sequelize, DataTypes) => { updatedBy: milestone.updatedBy, }, { transaction: options.transaction, - }), + }).then(() => mapWithStatusHistory(milestone)), afterUpdate: (milestone, options) => { if (milestone.changed().includes('status')) { @@ -106,9 +128,18 @@ module.exports = (sequelize, DataTypes) => { updatedBy: milestone.updatedBy, }, { transaction: options.transaction, - }); + }).then(() => mapWithStatusHistory(milestone)); + } + return mapWithStatusHistory(milestone); + }, + + afterFind: (milestone) => { + if (!milestone) return Promise.resolve(); + + if (Array.isArray(milestone)) { + return Promise.all(milestone.map(mapWithStatusHistory)); } - return Promise.resolve(); + return mapWithStatusHistory(milestone); }, }, }); diff --git a/src/routes/milestones/create.spec.js b/src/routes/milestones/create.spec.js index b9cec474..b5eed6a3 100644 --- a/src/routes/milestones/create.spec.js +++ b/src/routes/milestones/create.spec.js @@ -523,6 +523,14 @@ describe('CREATE milestone', () => { should.not.exist(resJson.deletedBy); should.not.exist(resJson.deletedAt); + // validate statusHistory + should.exist(resJson.statusHistory); + resJson.statusHistory.should.be.an('array'); + resJson.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(`${resJson.id}`); + }); + // eslint-disable-next-line no-unused-expressions server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.MILESTONE_ADDED).should.be.true; diff --git a/src/routes/milestones/get.spec.js b/src/routes/milestones/get.spec.js index fb0451d1..b9a8db58 100644 --- a/src/routes/milestones/get.spec.js +++ b/src/routes/milestones/get.spec.js @@ -133,6 +133,7 @@ describe('GET milestone', () => { // Create milestones models.Milestone.bulkCreate([ { + id: 1, timelineId: 1, name: 'milestone 1', duration: 2, @@ -155,6 +156,7 @@ describe('GET milestone', () => { updatedBy: 2, }, { + id: 2, timelineId: 1, name: 'milestone 2', duration: 3, @@ -170,6 +172,7 @@ describe('GET milestone', () => { updatedBy: 3, }, { + id: 3, timelineId: 1, name: 'milestone 3', duration: 4, @@ -187,7 +190,30 @@ describe('GET milestone', () => { deletedAt: '2018-05-04T00:00:00.000Z', }, ]) - .then(() => done()); + .then(() => + models.StatusHistory.bulkCreate([ + { + reference: 'milestone', + referenceId: '1', + status: 'active', + comment: 'comment', + createdBy: 1, + createdAt: '2018-05-15T00:00:00Z', + updatedBy: 1, + updatedAt: '2018-05-15T00:00:00Z', + }, + { + reference: 'milestone', + referenceId: '1', + status: 'active', + comment: 'comment', + createdBy: 1, + createdAt: '2018-05-15T00:00:00Z', + updatedBy: 1, + updatedAt: '2018-05-15T00:00:00Z', + }, + ]) + .then(() => done())); }); }); }); @@ -301,6 +327,14 @@ describe('GET milestone', () => { should.not.exist(resJson.deletedBy); should.not.exist(resJson.deletedAt); + // validate statusHistory + should.exist(resJson.statusHistory); + resJson.statusHistory.should.be.an('array'); + resJson.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(`${resJson.id}`); + }); + done(); }); }); diff --git a/src/routes/milestones/list.spec.js b/src/routes/milestones/list.spec.js index 21c07336..a4336ef4 100644 --- a/src/routes/milestones/list.spec.js +++ b/src/routes/milestones/list.spec.js @@ -5,6 +5,7 @@ import chai from 'chai'; import request from 'supertest'; import sleep from 'sleep'; import config from 'config'; +import _ from 'lodash'; import models from '../../models'; import server from '../../app'; @@ -58,6 +59,16 @@ const milestones = [ updatedBy: 2, createdAt: '2018-05-11T00:00:00.000Z', updatedAt: '2018-05-11T00:00:00.000Z', + statusHistory: [{ + reference: 'milestone', + referenceId: '1', + status: 'active', + comment: 'comment', + createdBy: 1, + createdAt: '2018-05-15T00:00:00Z', + updatedBy: 1, + updatedAt: '2018-05-15T00:00:00Z', + }], }, { id: 2, @@ -76,6 +87,16 @@ const milestones = [ updatedBy: 3, createdAt: '2018-05-11T00:00:00.000Z', updatedAt: '2018-05-11T00:00:00.000Z', + statusHistory: [{ + reference: 'milestone', + referenceId: '2', + status: 'active', + comment: 'comment', + createdBy: 1, + createdAt: '2018-05-15T00:00:00Z', + updatedBy: 1, + updatedAt: '2018-05-15T00:00:00Z', + }], }, ]; @@ -165,7 +186,10 @@ describe('LIST timelines', () => { .then(() => // Create timelines and milestones models.Timeline.bulkCreate(timelines) - .then(() => models.Milestone.bulkCreate(milestones))) + .then(() => { + const mappedMilstones = milestones.map(milestone => _.omit(milestone, ['statusHistory'])); + return models.Milestone.bulkCreate(mappedMilstones); + })) .then(() => { // Index to ES timelines[0].milestones = milestones; @@ -242,8 +266,15 @@ describe('LIST timelines', () => { const resJson = res.body.result.content; resJson.should.have.length(2); - resJson[0].should.be.eql(milestones[0]); - resJson[1].should.be.eql(milestones[1]); + resJson.forEach((milestone, index) => { + milestone.statusHistory.should.be.an('array'); + milestone.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(`${milestone.id}`); + }); + + milestone.should.be.eql(milestones[index]); + }); done(); }); diff --git a/src/routes/milestones/update.spec.js b/src/routes/milestones/update.spec.js index f38c9c7f..50c61591 100644 --- a/src/routes/milestones/update.spec.js +++ b/src/routes/milestones/update.spec.js @@ -252,6 +252,28 @@ describe('UPDATE Milestone', () => { updatedAt: '2018-05-11T00:00:00.000Z', }, ]))) + .then(() => models.StatusHistory.bulkCreate([ + { + reference: 'milestone', + referenceId: '1', + status: 'active', + comment: 'comment', + createdBy: 1, + createdAt: '2018-05-15T00:00:00Z', + updatedBy: 1, + updatedAt: '2018-05-15T00:00:00Z', + }, + { + reference: 'milestone', + referenceId: '2', + status: 'active', + comment: 'comment', + createdBy: 1, + createdAt: '2018-05-15T00:00:00Z', + updatedBy: 1, + updatedAt: '2018-05-15T00:00:00Z', + }, + ])) .then(() => done()); }); }); @@ -524,6 +546,14 @@ describe('UPDATE Milestone', () => { should.not.exist(resJson.deletedBy); should.not.exist(resJson.deletedAt); + // validate statusHistory + should.exist(resJson.statusHistory); + resJson.statusHistory.should.be.an('array'); + resJson.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(`${resJson.id}`); + }); + // eslint-disable-next-line no-unused-expressions server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.MILESTONE_UPDATED).should.be.true; diff --git a/src/routes/timelines/create.spec.js b/src/routes/timelines/create.spec.js index 20c88ed8..9ee655c2 100644 --- a/src/routes/timelines/create.spec.js +++ b/src/routes/timelines/create.spec.js @@ -529,6 +529,14 @@ describe('CREATE timeline', () => { should.exist(milestone.updatedAt); should.not.exist(milestone.deletedBy); should.not.exist(milestone.deletedAt); + + // validate statusHistory + should.exist(milestone.statusHistory); + milestone.statusHistory.should.be.an('array'); + milestone.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(`${milestone.id}`); + }); }); // eslint-disable-next-line no-unused-expressions diff --git a/src/routes/timelines/get.spec.js b/src/routes/timelines/get.spec.js index 2331295c..efb7953f 100644 --- a/src/routes/timelines/get.spec.js +++ b/src/routes/timelines/get.spec.js @@ -58,6 +58,29 @@ const milestones = [ }, ]; +const statusHistories = [ + { + reference: 'milestone', + referenceId: '1', + status: 'active', + comment: 'comment', + createdBy: 1, + createdAt: '2018-05-15T00:00:00Z', + updatedBy: 1, + updatedAt: '2018-05-15T00:00:00Z', + }, + { + reference: 'milestone', + referenceId: '2', + status: 'active', + comment: 'comment', + createdBy: 1, + createdAt: '2018-05-15T00:00:00Z', + updatedBy: 1, + updatedAt: '2018-05-15T00:00:00Z', + }, +]; + describe('GET timeline', () => { before((done) => { testUtil.clearDb() @@ -177,6 +200,7 @@ describe('GET timeline', () => { }, ])) .then(() => models.Milestone.bulkCreate(milestones)) + .then(() => models.StatusHistory.bulkCreate(statusHistories)) .then(() => done()); }); }); @@ -262,6 +286,15 @@ describe('GET timeline', () => { // Milestones resJson.milestones.should.have.length(2); + resJson.milestones.forEach((milestone) => { + // validate statusHistory + should.exist(milestone.statusHistory); + milestone.statusHistory.should.be.an('array'); + milestone.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(`${milestone.id}`); + }); + }); done(); }); diff --git a/src/routes/timelines/list.spec.js b/src/routes/timelines/list.spec.js index 2c1395c8..5931933a 100644 --- a/src/routes/timelines/list.spec.js +++ b/src/routes/timelines/list.spec.js @@ -75,6 +75,18 @@ const milestones = [ updatedBy: 2, createdAt: '2018-05-11T00:00:00.000Z', updatedAt: '2018-05-11T00:00:00.000Z', + statusHistory: [ + { + reference: 'milestone', + referenceId: '1', + status: 'active', + comment: 'comment', + createdBy: 1, + createdAt: '2018-05-15T00:00:00Z', + updatedBy: 1, + updatedAt: '2018-05-15T00:00:00Z', + }, + ], }, { id: 2, @@ -93,6 +105,18 @@ const milestones = [ updatedBy: 3, createdAt: '2018-05-11T00:00:00.000Z', updatedAt: '2018-05-11T00:00:00.000Z', + statusHistory: [ + { + reference: 'milestone', + referenceId: '2', + status: 'active', + comment: 'comment', + createdBy: 1, + createdAt: '2018-05-15T00:00:00Z', + updatedBy: 1, + updatedAt: '2018-05-15T00:00:00Z', + }, + ], }, ]; @@ -276,6 +300,15 @@ describe('LIST timelines', () => { // Milestones resJson[0].milestones.should.have.length(2); + resJson[0].milestones.forEach((milestone) => { + // validate statusHistory + should.exist(milestone.statusHistory); + milestone.statusHistory.should.be.an('array'); + milestone.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(`${milestone.id}`); + }); + }); done(); }); diff --git a/src/routes/timelines/update.spec.js b/src/routes/timelines/update.spec.js index 72fffcb3..fdafa66c 100644 --- a/src/routes/timelines/update.spec.js +++ b/src/routes/timelines/update.spec.js @@ -61,6 +61,28 @@ const milestones = [ updatedAt: '2018-05-11T00:00:00.000Z', }, ]; +const statusHistories = [ + { + reference: 'milestone', + referenceId: '1', + status: 'active', + comment: 'comment', + createdBy: 1, + createdAt: '2018-05-15T00:00:00Z', + updatedBy: 1, + updatedAt: '2018-05-15T00:00:00Z', + }, + { + reference: 'milestone', + referenceId: '2', + status: 'active', + comment: 'comment', + createdBy: 1, + createdAt: '2018-05-15T00:00:00Z', + updatedBy: 1, + updatedAt: '2018-05-15T00:00:00Z', + }, +]; describe('UPDATE timeline', () => { beforeEach((done) => { @@ -181,6 +203,7 @@ describe('UPDATE timeline', () => { }, ])) .then(() => models.Milestone.bulkCreate(milestones)) + .then(() => models.StatusHistory.bulkCreate(statusHistories)) .then(() => done()); }); }); @@ -492,6 +515,18 @@ describe('UPDATE timeline', () => { should.not.exist(resJson.deletedAt); should.not.exist(resJson.deletedBy); + // Milestones + resJson.milestones.should.have.length(2); + resJson.milestones.forEach((milestone) => { + // validate statusHistory + should.exist(milestone.statusHistory); + milestone.statusHistory.should.be.an('array'); + milestone.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(`${milestone.id}`); + }); + }); + // eslint-disable-next-line no-unused-expressions server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.TIMELINE_UPDATED).should.be.true; diff --git a/swagger.yaml b/swagger.yaml index 0e4fa918..54d651ec 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -3173,7 +3173,7 @@ definitions: - buildingBlockKey properties: conditions: - type: string + type: string price: type: number format: float @@ -3186,7 +3186,7 @@ definitions: metadata: type: object buildingBlockKey: - type: string + type: string type: type: string description: project type @@ -4859,6 +4859,7 @@ definitions: - type: object required: - id + - statusHistory - createdAt - createdBy - updatedAt @@ -4868,6 +4869,8 @@ definitions: type: number format: int64 description: the id + statusHistory: + $ref: '#/definitions/StatusHistory' createdAt: type: string description: Datetime (GMT) when object was created @@ -5524,3 +5527,46 @@ definitions: config: description: config json type: object + StatusHistory: + title: Status history object + type: object + required: + - id + - status + - reference + - referenceId + - comment + properties: + id: + type: string + description: the id + status: + type: string + description: the status + reference: + type: string + description: the referenced model + referenceId: + type: string + description: the referenced id + comment: + type: string + description: the comment + createdAt: + type: string + description: Datetime (GMT) when object was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this object + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when object was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this object + readOnly: true From f554bbe5ead6eb7b2c78255abfdc68897d073c4d Mon Sep 17 00:00:00 2001 From: Muhamad Fikri Alhawarizmi Date: Tue, 25 Jun 2019 16:56:32 +0700 Subject: [PATCH 6/9] improve milestone hooks and change `referenceId` in milestone to BIGINT --- src/models/milestone.js | 63 +++++++++++++++++++++------- src/models/statusHistory.js | 2 +- src/routes/milestones/create.spec.js | 12 ++++-- src/routes/milestones/delete.spec.js | 3 ++ src/routes/milestones/get.spec.js | 28 ++----------- src/routes/milestones/list.spec.js | 44 +++++++------------ src/routes/milestones/update.js | 2 +- src/routes/milestones/update.spec.js | 35 ++++------------ src/routes/timelines/create.spec.js | 3 +- src/routes/timelines/delete.spec.js | 2 + src/routes/timelines/get.spec.js | 27 +----------- src/routes/timelines/list.spec.js | 36 ++++------------ src/routes/timelines/update.spec.js | 26 +----------- 13 files changed, 100 insertions(+), 183 deletions(-) diff --git a/src/models/milestone.js b/src/models/milestone.js index 746a437e..54072443 100644 --- a/src/models/milestone.js +++ b/src/models/milestone.js @@ -5,22 +5,42 @@ import models from '../models'; /** * Populate and map milestone model with statusHistory + * NOTE that this function mutates milestone * @param {Object} milestone the milestone * @returns {Promise} promise */ const mapWithStatusHistory = async (milestone) => { - try { - const statusHistory = await models.StatusHistory.findAll({ - where: { - referenceId: milestone.id.toString(), - reference: 'milestone', - }, - order: [['createdAt', 'desc']], - raw: true, - }); - return _.merge(milestone, { dataValues: { statusHistory } }); - } catch (err) { - return _.merge(milestone, { dataValues: { statusHistory: [] } }); + if (Array.isArray(milestone)) { + try { + const allStatusHistory = await models.StatusHistory.findAll({ + where: { + referenceId: { $in: milestone.map(m => m.dataValues.id) }, + reference: 'milestone', + }, + order: [['createdAt', 'desc']], + raw: true, + }); + return milestone.map((m, index) => { + const statusHistory = allStatusHistory.filter(s => s.referenceId === m.dataValues.id); + return _.merge(milestone[index], { dataValues: { statusHistory } }); + }); + } catch (err) { + return milestone.map((m, index) => _.merge(milestone[index], { dataValues: { statusHistory: [] } })); + } + } else { + try { + const statusHistory = await models.StatusHistory.findAll({ + where: { + referenceId: milestone.dataValues.id, + reference: 'milestone', + }, + order: [['createdAt', 'desc']], + raw: true, + }); + return _.merge(milestone, { dataValues: { statusHistory } }); + } catch (err) { + return _.merge(milestone, { dataValues: { statusHistory: [] } }); + } } }; @@ -117,6 +137,21 @@ module.exports = (sequelize, DataTypes) => { transaction: options.transaction, }).then(() => mapWithStatusHistory(milestone)), + afterBulkCreate: (milestones, options) => { + const listStatusHistory = milestones.map(({ dataValues }) => ({ + reference: 'milestone', + referenceId: dataValues.id, + status: dataValues.status, + comment: null, + createdBy: dataValues.createdBy, + updatedBy: dataValues.updatedBy, + })); + + return models.StatusHistory.bulkCreate(listStatusHistory, { + transaction: options.transaction, + }).then(() => mapWithStatusHistory(milestones)); + }, + afterUpdate: (milestone, options) => { if (milestone.changed().includes('status')) { return models.StatusHistory.create({ @@ -135,10 +170,6 @@ module.exports = (sequelize, DataTypes) => { afterFind: (milestone) => { if (!milestone) return Promise.resolve(); - - if (Array.isArray(milestone)) { - return Promise.all(milestone.map(mapWithStatusHistory)); - } return mapWithStatusHistory(milestone); }, }, diff --git a/src/models/statusHistory.js b/src/models/statusHistory.js index e9970b8b..b73d3650 100644 --- a/src/models/statusHistory.js +++ b/src/models/statusHistory.js @@ -7,7 +7,7 @@ module.exports = function defineStatusHistory(sequelize, DataTypes) { const StatusHistory = sequelize.define('StatusHistory', { id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, reference: { type: DataTypes.STRING, allowNull: false }, - referenceId: { type: DataTypes.STRING, allowNull: false }, + referenceId: { type: DataTypes.BIGINT, allowNull: false }, status: { type: DataTypes.STRING, allowNull: false, diff --git a/src/routes/milestones/create.spec.js b/src/routes/milestones/create.spec.js index b5eed6a3..ec04afda 100644 --- a/src/routes/milestones/create.spec.js +++ b/src/routes/milestones/create.spec.js @@ -142,6 +142,7 @@ describe('CREATE milestone', () => { // Create milestones models.Milestone.bulkCreate([ { + id: 11, timelineId: 1, name: 'milestone 1', duration: 2, @@ -164,6 +165,7 @@ describe('CREATE milestone', () => { updatedBy: 2, }, { + id: 12, timelineId: 1, name: 'milestone 2', duration: 3, @@ -179,6 +181,7 @@ describe('CREATE milestone', () => { updatedBy: 3, }, { + id: 13, timelineId: 1, name: 'milestone 3', duration: 4, @@ -526,9 +529,10 @@ describe('CREATE milestone', () => { // validate statusHistory should.exist(resJson.statusHistory); resJson.statusHistory.should.be.an('array'); + resJson.statusHistory.length.should.be.eql(1); resJson.statusHistory.forEach((statusHistory) => { statusHistory.reference.should.be.eql('milestone'); - statusHistory.referenceId.should.be.eql(`${resJson.id}`); + statusHistory.referenceId.should.be.eql(resJson.id); }); // eslint-disable-next-line no-unused-expressions @@ -538,11 +542,11 @@ describe('CREATE milestone', () => { models.Milestone.findAll({ where: { timelineId: 1 } }) .then((milestones) => { _.each(milestones, (milestone) => { - if (milestone.id === 1) { + if (milestone.id === 11) { milestone.order.should.be.eql(1); - } else if (milestone.id === 2) { + } else if (milestone.id === 12) { milestone.order.should.be.eql(2 + 1); - } else if (milestone.id === 3) { + } else if (milestone.id === 13) { milestone.order.should.be.eql(3 + 1); } }); diff --git a/src/routes/milestones/delete.spec.js b/src/routes/milestones/delete.spec.js index c756b7b0..5b237d1d 100644 --- a/src/routes/milestones/delete.spec.js +++ b/src/routes/milestones/delete.spec.js @@ -160,6 +160,7 @@ describe('DELETE milestone', () => { // Create milestones models.Milestone.bulkCreate([ { + id: 1, timelineId: 1, name: 'milestone 1', duration: 2, @@ -182,6 +183,7 @@ describe('DELETE milestone', () => { updatedBy: 2, }, { + id: 2, timelineId: 1, name: 'milestone 2', duration: 3, @@ -197,6 +199,7 @@ describe('DELETE milestone', () => { updatedBy: 3, }, { + id: 3, timelineId: 1, name: 'milestone 3', duration: 4, diff --git a/src/routes/milestones/get.spec.js b/src/routes/milestones/get.spec.js index b9a8db58..5ae3adf9 100644 --- a/src/routes/milestones/get.spec.js +++ b/src/routes/milestones/get.spec.js @@ -190,30 +190,7 @@ describe('GET milestone', () => { deletedAt: '2018-05-04T00:00:00.000Z', }, ]) - .then(() => - models.StatusHistory.bulkCreate([ - { - reference: 'milestone', - referenceId: '1', - status: 'active', - comment: 'comment', - createdBy: 1, - createdAt: '2018-05-15T00:00:00Z', - updatedBy: 1, - updatedAt: '2018-05-15T00:00:00Z', - }, - { - reference: 'milestone', - referenceId: '1', - status: 'active', - comment: 'comment', - createdBy: 1, - createdAt: '2018-05-15T00:00:00Z', - updatedBy: 1, - updatedAt: '2018-05-15T00:00:00Z', - }, - ]) - .then(() => done())); + .then(() => done()); }); }); }); @@ -330,9 +307,10 @@ describe('GET milestone', () => { // validate statusHistory should.exist(resJson.statusHistory); resJson.statusHistory.should.be.an('array'); + resJson.statusHistory.length.should.be.eql(1); resJson.statusHistory.forEach((statusHistory) => { statusHistory.reference.should.be.eql('milestone'); - statusHistory.referenceId.should.be.eql(`${resJson.id}`); + statusHistory.referenceId.should.be.eql(resJson.id); }); done(); diff --git a/src/routes/milestones/list.spec.js b/src/routes/milestones/list.spec.js index a4336ef4..48d631f3 100644 --- a/src/routes/milestones/list.spec.js +++ b/src/routes/milestones/list.spec.js @@ -51,6 +51,7 @@ const milestones = [ detail2: [1, 2, 3], }, order: 1, + hidden: false, plannedText: 'plannedText 1', activeText: 'activeText 1', completedText: 'completedText 1', @@ -59,16 +60,6 @@ const milestones = [ updatedBy: 2, createdAt: '2018-05-11T00:00:00.000Z', updatedAt: '2018-05-11T00:00:00.000Z', - statusHistory: [{ - reference: 'milestone', - referenceId: '1', - status: 'active', - comment: 'comment', - createdBy: 1, - createdAt: '2018-05-15T00:00:00Z', - updatedBy: 1, - updatedAt: '2018-05-15T00:00:00Z', - }], }, { id: 2, @@ -79,6 +70,7 @@ const milestones = [ status: 'open', type: 'type2', order: 2, + hidden: false, plannedText: 'plannedText 2', activeText: 'activeText 2', completedText: 'completedText 2', @@ -87,16 +79,6 @@ const milestones = [ updatedBy: 3, createdAt: '2018-05-11T00:00:00.000Z', updatedAt: '2018-05-11T00:00:00.000Z', - statusHistory: [{ - reference: 'milestone', - referenceId: '2', - status: 'active', - comment: 'comment', - createdBy: 1, - createdAt: '2018-05-15T00:00:00Z', - updatedBy: 1, - updatedAt: '2018-05-15T00:00:00Z', - }], }, ]; @@ -186,13 +168,10 @@ describe('LIST timelines', () => { .then(() => // Create timelines and milestones models.Timeline.bulkCreate(timelines) - .then(() => { - const mappedMilstones = milestones.map(milestone => _.omit(milestone, ['statusHistory'])); - return models.Milestone.bulkCreate(mappedMilstones); - })) - .then(() => { + .then(() => models.Milestone.bulkCreate(milestones))) + .then((mappedMilestones) => { // Index to ES - timelines[0].milestones = milestones; + timelines[0].milestones = mappedMilestones.map(({ dataValues }) => dataValues); timelines[0].projectId = 1; return server.services.es.index({ index: ES_TIMELINE_INDEX, @@ -268,12 +247,15 @@ describe('LIST timelines', () => { resJson.forEach((milestone, index) => { milestone.statusHistory.should.be.an('array'); + milestone.statusHistory.length.should.be.eql(1); milestone.statusHistory.forEach((statusHistory) => { statusHistory.reference.should.be.eql('milestone'); - statusHistory.referenceId.should.be.eql(`${milestone.id}`); + statusHistory.referenceId.should.be.eql(milestone.id); }); - milestone.should.be.eql(milestones[index]); + const m = _.omit(milestone, ['statusHistory']); + + m.should.be.eql(milestones[index]); }); done(); @@ -349,8 +331,10 @@ describe('LIST timelines', () => { const resJson = res.body.result.content; resJson.should.have.length(2); - resJson[0].should.be.eql(milestones[1]); - resJson[1].should.be.eql(milestones[0]); + const m1 = _.omit(resJson[0], ['statusHistory']); + const m2 = _.omit(resJson[1], ['statusHistory']); + m1.should.be.eql(milestones[1]); + m2.should.be.eql(milestones[0]); done(); }); diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index 59a8a3bf..f3cd83cb 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -170,7 +170,7 @@ module.exports = [ return Promise.reject(apiErr); } const statusHistory = await models.StatusHistory.findAll({ - where: { referenceId: milestone.id.toString() }, + where: { referenceId: milestone.id }, order: [['createdAt', 'desc']], attributes: ['status'], limit: 2, diff --git a/src/routes/milestones/update.spec.js b/src/routes/milestones/update.spec.js index 50c61591..5db038df 100644 --- a/src/routes/milestones/update.spec.js +++ b/src/routes/milestones/update.spec.js @@ -252,29 +252,7 @@ describe('UPDATE Milestone', () => { updatedAt: '2018-05-11T00:00:00.000Z', }, ]))) - .then(() => models.StatusHistory.bulkCreate([ - { - reference: 'milestone', - referenceId: '1', - status: 'active', - comment: 'comment', - createdBy: 1, - createdAt: '2018-05-15T00:00:00Z', - updatedBy: 1, - updatedAt: '2018-05-15T00:00:00Z', - }, - { - reference: 'milestone', - referenceId: '2', - status: 'active', - comment: 'comment', - createdBy: 1, - createdAt: '2018-05-15T00:00:00Z', - updatedBy: 1, - updatedAt: '2018-05-15T00:00:00Z', - }, - ])) - .then(() => done()); + .then(() => done()); }); }); }); @@ -549,9 +527,10 @@ describe('UPDATE Milestone', () => { // validate statusHistory should.exist(resJson.statusHistory); resJson.statusHistory.should.be.an('array'); + resJson.statusHistory.length.should.be.eql(2); resJson.statusHistory.forEach((statusHistory) => { statusHistory.reference.should.be.eql('milestone'); - statusHistory.referenceId.should.be.eql(`${resJson.id}`); + statusHistory.referenceId.should.be.eql(resJson.id); }); // eslint-disable-next-line no-unused-expressions @@ -1160,7 +1139,7 @@ describe('UPDATE Milestone', () => { return models.StatusHistory.findAll({ where: { reference: 'milestone', - referenceId: milestone.id.toString(), + referenceId: milestone.id, status: milestone.status, comment: 'milestone paused', }, @@ -1222,7 +1201,7 @@ describe('UPDATE Milestone', () => { ]).then(() => models.StatusHistory.bulkCreate([ { reference: 'milestone', - referenceId: '7', + referenceId: 7, status: 'active', comment: 'comment', createdBy: 1, @@ -1232,7 +1211,7 @@ describe('UPDATE Milestone', () => { }, { reference: 'milestone', - referenceId: '7', + referenceId: 7, status: 'paused', comment: 'comment', createdBy: 1, @@ -1258,7 +1237,7 @@ describe('UPDATE Milestone', () => { return models.StatusHistory.findAll({ where: { reference: 'milestone', - referenceId: milestone.id.toString(), + referenceId: milestone.id, status: 'active', comment: 'new comment', }, diff --git a/src/routes/timelines/create.spec.js b/src/routes/timelines/create.spec.js index 9ee655c2..c6ab1bc8 100644 --- a/src/routes/timelines/create.spec.js +++ b/src/routes/timelines/create.spec.js @@ -533,9 +533,10 @@ describe('CREATE timeline', () => { // validate statusHistory should.exist(milestone.statusHistory); milestone.statusHistory.should.be.an('array'); + milestone.statusHistory.length.should.be.eql(1); milestone.statusHistory.forEach((statusHistory) => { statusHistory.reference.should.be.eql('milestone'); - statusHistory.referenceId.should.be.eql(`${milestone.id}`); + statusHistory.referenceId.should.be.eql(milestone.id); }); }); diff --git a/src/routes/timelines/delete.spec.js b/src/routes/timelines/delete.spec.js index 68c505b2..f609397e 100644 --- a/src/routes/timelines/delete.spec.js +++ b/src/routes/timelines/delete.spec.js @@ -159,6 +159,7 @@ describe('DELETE timeline', () => { // Create milestones models.Milestone.bulkCreate([ { + id: 1, timelineId: 1, name: 'milestone 1', duration: 2, @@ -181,6 +182,7 @@ describe('DELETE timeline', () => { updatedBy: 2, }, { + id: 2, timelineId: 1, name: 'milestone 2', duration: 2, diff --git a/src/routes/timelines/get.spec.js b/src/routes/timelines/get.spec.js index efb7953f..da22d117 100644 --- a/src/routes/timelines/get.spec.js +++ b/src/routes/timelines/get.spec.js @@ -58,29 +58,6 @@ const milestones = [ }, ]; -const statusHistories = [ - { - reference: 'milestone', - referenceId: '1', - status: 'active', - comment: 'comment', - createdBy: 1, - createdAt: '2018-05-15T00:00:00Z', - updatedBy: 1, - updatedAt: '2018-05-15T00:00:00Z', - }, - { - reference: 'milestone', - referenceId: '2', - status: 'active', - comment: 'comment', - createdBy: 1, - createdAt: '2018-05-15T00:00:00Z', - updatedBy: 1, - updatedAt: '2018-05-15T00:00:00Z', - }, -]; - describe('GET timeline', () => { before((done) => { testUtil.clearDb() @@ -200,7 +177,6 @@ describe('GET timeline', () => { }, ])) .then(() => models.Milestone.bulkCreate(milestones)) - .then(() => models.StatusHistory.bulkCreate(statusHistories)) .then(() => done()); }); }); @@ -290,9 +266,10 @@ describe('GET timeline', () => { // validate statusHistory should.exist(milestone.statusHistory); milestone.statusHistory.should.be.an('array'); + milestone.statusHistory.length.should.be.eql(1); milestone.statusHistory.forEach((statusHistory) => { statusHistory.reference.should.be.eql('milestone'); - statusHistory.referenceId.should.be.eql(`${milestone.id}`); + statusHistory.referenceId.should.be.eql(milestone.id); }); }); diff --git a/src/routes/timelines/list.spec.js b/src/routes/timelines/list.spec.js index 5931933a..4f68c843 100644 --- a/src/routes/timelines/list.spec.js +++ b/src/routes/timelines/list.spec.js @@ -75,18 +75,6 @@ const milestones = [ updatedBy: 2, createdAt: '2018-05-11T00:00:00.000Z', updatedAt: '2018-05-11T00:00:00.000Z', - statusHistory: [ - { - reference: 'milestone', - referenceId: '1', - status: 'active', - comment: 'comment', - createdBy: 1, - createdAt: '2018-05-15T00:00:00Z', - updatedBy: 1, - updatedAt: '2018-05-15T00:00:00Z', - }, - ], }, { id: 2, @@ -105,18 +93,6 @@ const milestones = [ updatedBy: 3, createdAt: '2018-05-11T00:00:00.000Z', updatedAt: '2018-05-11T00:00:00.000Z', - statusHistory: [ - { - reference: 'milestone', - referenceId: '2', - status: 'active', - comment: 'comment', - createdBy: 1, - createdAt: '2018-05-15T00:00:00Z', - updatedBy: 1, - updatedAt: '2018-05-15T00:00:00Z', - }, - ], }, ]; @@ -206,14 +182,17 @@ describe('LIST timelines', () => { ])) .then(() => // Create timelines - models.Timeline.bulkCreate(timelines, { returning: true })) - .then(createdTimelines => + Promise.all([ + models.Timeline.bulkCreate(timelines, { returning: true }), + models.Milestone.bulkCreate(milestones), + ])) + .then(([createdTimelines, mappedMilestones]) => // Index to ES Promise.all(_.map(createdTimelines, (createdTimeline) => { const timelineJson = _.omit(createdTimeline.toJSON(), 'deletedAt', 'deletedBy'); timelineJson.projectId = createdTimeline.id !== 3 ? 1 : 2; if (timelineJson.id === 1) { - timelineJson.milestones = milestones; + timelineJson.milestones = mappedMilestones; } return server.services.es.index({ index: ES_TIMELINE_INDEX, @@ -304,9 +283,10 @@ describe('LIST timelines', () => { // validate statusHistory should.exist(milestone.statusHistory); milestone.statusHistory.should.be.an('array'); + milestone.statusHistory.length.should.be.eql(1); milestone.statusHistory.forEach((statusHistory) => { statusHistory.reference.should.be.eql('milestone'); - statusHistory.referenceId.should.be.eql(`${milestone.id}`); + statusHistory.referenceId.should.be.eql(milestone.id); }); }); diff --git a/src/routes/timelines/update.spec.js b/src/routes/timelines/update.spec.js index fdafa66c..d0726cf1 100644 --- a/src/routes/timelines/update.spec.js +++ b/src/routes/timelines/update.spec.js @@ -61,28 +61,6 @@ const milestones = [ updatedAt: '2018-05-11T00:00:00.000Z', }, ]; -const statusHistories = [ - { - reference: 'milestone', - referenceId: '1', - status: 'active', - comment: 'comment', - createdBy: 1, - createdAt: '2018-05-15T00:00:00Z', - updatedBy: 1, - updatedAt: '2018-05-15T00:00:00Z', - }, - { - reference: 'milestone', - referenceId: '2', - status: 'active', - comment: 'comment', - createdBy: 1, - createdAt: '2018-05-15T00:00:00Z', - updatedBy: 1, - updatedAt: '2018-05-15T00:00:00Z', - }, -]; describe('UPDATE timeline', () => { beforeEach((done) => { @@ -203,7 +181,6 @@ describe('UPDATE timeline', () => { }, ])) .then(() => models.Milestone.bulkCreate(milestones)) - .then(() => models.StatusHistory.bulkCreate(statusHistories)) .then(() => done()); }); }); @@ -521,9 +498,10 @@ describe('UPDATE timeline', () => { // validate statusHistory should.exist(milestone.statusHistory); milestone.statusHistory.should.be.an('array'); + milestone.statusHistory.length.should.be.eql(1); milestone.statusHistory.forEach((statusHistory) => { statusHistory.reference.should.be.eql('milestone'); - statusHistory.referenceId.should.be.eql(`${milestone.id}`); + statusHistory.referenceId.should.be.eql(milestone.id); }); }); From 93a1ff26dbb24f3fe37f1494c33387f40d9c358a Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Thu, 27 Jun 2019 14:27:55 +0800 Subject: [PATCH 7/9] support transaction for creating timeline so filing creating milestones or statusHistory reverts timeline too small code improvements --- src/constants.js | 4 ++ src/models/milestone.js | 76 ++++++++++++++++------------------ src/routes/timelines/create.js | 6 +-- 3 files changed, 43 insertions(+), 43 deletions(-) diff --git a/src/constants.js b/src/constants.js index dae629f7..f0ac0f21 100644 --- a/src/constants.js +++ b/src/constants.js @@ -165,6 +165,10 @@ export const TIMELINE_REFERENCES = { PRODUCT: 'product', }; +export const STATUS_HISTORY_REFERENCES = { + MILESTONE: 'milestone', +}; + export const MILESTONE_TEMPLATE_REFERENCES = { PRODUCT_TEMPLATE: 'productTemplate', }; diff --git a/src/models/milestone.js b/src/models/milestone.js index 54072443..f32884a1 100644 --- a/src/models/milestone.js +++ b/src/models/milestone.js @@ -1,47 +1,43 @@ import _ from 'lodash'; import moment from 'moment'; import models from '../models'; +import { STATUS_HISTORY_REFERENCES } from '../constants'; /* eslint-disable valid-jsdoc */ /** * Populate and map milestone model with statusHistory * NOTE that this function mutates milestone - * @param {Object} milestone the milestone + * + * @param {Array|Object} milestone one milestone or list of milestones + * * @returns {Promise} promise */ -const mapWithStatusHistory = async (milestone) => { +const populateWithStatusHistory = async (milestone) => { if (Array.isArray(milestone)) { - try { - const allStatusHistory = await models.StatusHistory.findAll({ - where: { - referenceId: { $in: milestone.map(m => m.dataValues.id) }, - reference: 'milestone', - }, - order: [['createdAt', 'desc']], - raw: true, - }); - return milestone.map((m, index) => { - const statusHistory = allStatusHistory.filter(s => s.referenceId === m.dataValues.id); - return _.merge(milestone[index], { dataValues: { statusHistory } }); - }); - } catch (err) { - return milestone.map((m, index) => _.merge(milestone[index], { dataValues: { statusHistory: [] } })); - } - } else { - try { - const statusHistory = await models.StatusHistory.findAll({ - where: { - referenceId: milestone.dataValues.id, - reference: 'milestone', - }, - order: [['createdAt', 'desc']], - raw: true, - }); - return _.merge(milestone, { dataValues: { statusHistory } }); - } catch (err) { - return _.merge(milestone, { dataValues: { statusHistory: [] } }); - } + const allStatusHistory = await models.StatusHistory.findAll({ + where: { + referenceId: { $in: milestone.map(m => m.dataValues.id) }, + reference: 'milestone', + }, + order: [['createdAt', 'desc']], + raw: true, + }); + + return milestone.map((m, index) => { + const statusHistory = allStatusHistory.filter(s => s.referenceId === m.dataValues.id); + return _.merge(milestone[index], { dataValues: { statusHistory } }); + }); } + + const statusHistory = await models.StatusHistory.findAll({ + where: { + referenceId: milestone.dataValues.id, + reference: 'milestone', + }, + order: [['createdAt', 'desc']], + raw: true, + }); + return _.merge(milestone, { dataValues: { statusHistory } }); }; /** @@ -127,7 +123,7 @@ module.exports = (sequelize, DataTypes) => { }, hooks: { afterCreate: (milestone, options) => models.StatusHistory.create({ - reference: 'milestone', + reference: STATUS_HISTORY_REFERENCES.MILESTONE, referenceId: milestone.id, status: milestone.status, comment: null, @@ -135,11 +131,11 @@ module.exports = (sequelize, DataTypes) => { updatedBy: milestone.updatedBy, }, { transaction: options.transaction, - }).then(() => mapWithStatusHistory(milestone)), + }).then(() => populateWithStatusHistory(milestone)), afterBulkCreate: (milestones, options) => { const listStatusHistory = milestones.map(({ dataValues }) => ({ - reference: 'milestone', + reference: STATUS_HISTORY_REFERENCES.MILESTONE, referenceId: dataValues.id, status: dataValues.status, comment: null, @@ -149,13 +145,13 @@ module.exports = (sequelize, DataTypes) => { return models.StatusHistory.bulkCreate(listStatusHistory, { transaction: options.transaction, - }).then(() => mapWithStatusHistory(milestones)); + }).then(() => populateWithStatusHistory(milestones)); }, afterUpdate: (milestone, options) => { if (milestone.changed().includes('status')) { return models.StatusHistory.create({ - reference: 'milestone', + reference: STATUS_HISTORY_REFERENCES.MILESTONE, referenceId: milestone.id, status: milestone.status, comment: options.comment || null, @@ -163,14 +159,14 @@ module.exports = (sequelize, DataTypes) => { updatedBy: milestone.updatedBy, }, { transaction: options.transaction, - }).then(() => mapWithStatusHistory(milestone)); + }).then(() => populateWithStatusHistory(milestone)); } - return mapWithStatusHistory(milestone); + return populateWithStatusHistory(milestone); }, afterFind: (milestone) => { if (!milestone) return Promise.resolve(); - return mapWithStatusHistory(milestone); + return populateWithStatusHistory(milestone); }, }, }); diff --git a/src/routes/timelines/create.js b/src/routes/timelines/create.js index 44ec10be..a37b7adb 100644 --- a/src/routes/timelines/create.js +++ b/src/routes/timelines/create.js @@ -51,9 +51,9 @@ module.exports = [ let result; // Save to DB - models.sequelize.transaction(() => { + models.sequelize.transaction((tx) => { req.log.debug('Started transaction'); - return models.Timeline.create(entity) + return models.Timeline.create(entity, { transaction: tx }) .then((createdEntity) => { // Omit deletedAt, deletedBy result = _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'); @@ -97,7 +97,7 @@ module.exports = [ } return milestone; }); - return models.Milestone.bulkCreate(milestones, { returning: true }) + return models.Milestone.bulkCreate(milestones, { returning: true, transaction: tx }) .then((createdMilestones) => { req.log.debug('Milestones created for timeline with template id %d', templateId); result.milestones = _.map(createdMilestones, cm => _.omit(cm.toJSON(), 'deletedAt', 'deletedBy')); From 1c48448e6e5a5ee793e7b5372d8ef0679214919a Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Thu, 27 Jun 2019 19:25:31 +0800 Subject: [PATCH 8/9] fixed some cases for broken status history populating fixed and improved some related unit tests --- src/models/milestone.js | 31 ++++++++++++-------- src/routes/milestones/list.spec.js | 13 ++++----- src/routes/milestones/update.js | 4 +-- src/routes/milestones/update.spec.js | 36 ++++++------------------ src/routes/timelines/list.spec.js | 42 ++++++++++++++++------------ 5 files changed, 61 insertions(+), 65 deletions(-) diff --git a/src/models/milestone.js b/src/models/milestone.js index f32884a1..ee79c860 100644 --- a/src/models/milestone.js +++ b/src/models/milestone.js @@ -9,14 +9,23 @@ import { STATUS_HISTORY_REFERENCES } from '../constants'; * NOTE that this function mutates milestone * * @param {Array|Object} milestone one milestone or list of milestones + * @param {Object} options options which has been used to call main method * * @returns {Promise} promise */ -const populateWithStatusHistory = async (milestone) => { +const populateWithStatusHistory = async (milestone, options) => { + // depend on this option `milestone` is a sequlize ORM object or plain JS object + const isRaw = !!_.get(options, 'raw'); + const getMilestoneId = m => ( + isRaw ? m.id : m.dataValues.id + ); + const formatMilestone = statusHistory => ( + isRaw ? { statusHistory } : { dataValues: { statusHistory } } + ); if (Array.isArray(milestone)) { const allStatusHistory = await models.StatusHistory.findAll({ where: { - referenceId: { $in: milestone.map(m => m.dataValues.id) }, + referenceId: { $in: milestone.map(getMilestoneId) }, reference: 'milestone', }, order: [['createdAt', 'desc']], @@ -24,20 +33,20 @@ const populateWithStatusHistory = async (milestone) => { }); return milestone.map((m, index) => { - const statusHistory = allStatusHistory.filter(s => s.referenceId === m.dataValues.id); - return _.merge(milestone[index], { dataValues: { statusHistory } }); + const statusHistory = _.filter(allStatusHistory, { referenceId: getMilestoneId(m) }); + return _.merge(milestone[index], formatMilestone(statusHistory)); }); } const statusHistory = await models.StatusHistory.findAll({ where: { - referenceId: milestone.dataValues.id, + referenceId: getMilestoneId(milestone), reference: 'milestone', }, order: [['createdAt', 'desc']], raw: true, }); - return _.merge(milestone, { dataValues: { statusHistory } }); + return _.merge(milestone, formatMilestone(statusHistory)); }; /** @@ -131,7 +140,7 @@ module.exports = (sequelize, DataTypes) => { updatedBy: milestone.updatedBy, }, { transaction: options.transaction, - }).then(() => populateWithStatusHistory(milestone)), + }).then(() => populateWithStatusHistory(milestone, options)), afterBulkCreate: (milestones, options) => { const listStatusHistory = milestones.map(({ dataValues }) => ({ @@ -145,7 +154,7 @@ module.exports = (sequelize, DataTypes) => { return models.StatusHistory.bulkCreate(listStatusHistory, { transaction: options.transaction, - }).then(() => populateWithStatusHistory(milestones)); + }).then(() => populateWithStatusHistory(milestones, options)); }, afterUpdate: (milestone, options) => { @@ -161,12 +170,12 @@ module.exports = (sequelize, DataTypes) => { transaction: options.transaction, }).then(() => populateWithStatusHistory(milestone)); } - return populateWithStatusHistory(milestone); + return populateWithStatusHistory(milestone, options); }, - afterFind: (milestone) => { + afterFind: (milestone, options) => { if (!milestone) return Promise.resolve(); - return populateWithStatusHistory(milestone); + return populateWithStatusHistory(milestone, options); }, }, }); diff --git a/src/routes/milestones/list.spec.js b/src/routes/milestones/list.spec.js index 48d631f3..5ab96537 100644 --- a/src/routes/milestones/list.spec.js +++ b/src/routes/milestones/list.spec.js @@ -82,7 +82,7 @@ const milestones = [ }, ]; -describe('LIST timelines', () => { +describe('LIST milestones', () => { before(function beforeHook(done) { this.timeout(10000); testUtil.clearDb() @@ -165,13 +165,12 @@ describe('LIST timelines', () => { updatedBy: 2, }, ])) - .then(() => - // Create timelines and milestones - models.Timeline.bulkCreate(timelines) - .then(() => models.Milestone.bulkCreate(milestones))) - .then((mappedMilestones) => { + // Create timelines and milestones + .then(() => models.Timeline.bulkCreate(timelines)) + .then(() => models.Milestone.bulkCreate(milestones)) + .then((createdMilestones) => { // Index to ES - timelines[0].milestones = mappedMilestones.map(({ dataValues }) => dataValues); + timelines[0].milestones = _.map(createdMilestones, cm => _.omit(cm.toJSON(), 'deletedAt', 'deletedBy')); timelines[0].projectId = 1; return server.services.es.index({ index: ES_TIMELINE_INDEX, diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index f3cd83cb..4b68e72d 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -171,8 +171,8 @@ module.exports = [ } const statusHistory = await models.StatusHistory.findAll({ where: { referenceId: milestone.id }, - order: [['createdAt', 'desc']], - attributes: ['status'], + order: [['createdAt', 'desc'], ['id', 'desc']], + attributes: ['status', 'id'], limit: 2, raw: true, }); diff --git a/src/routes/milestones/update.spec.js b/src/routes/milestones/update.spec.js index 5db038df..382d2eab 100644 --- a/src/routes/milestones/update.spec.js +++ b/src/routes/milestones/update.spec.js @@ -1178,8 +1178,8 @@ describe('UPDATE Milestone', () => { duration: 2, startDate: '2018-05-13T00:00:00.000Z', endDate: '2018-05-14T00:00:00.000Z', - completionDate: '2018-05-15T00:00:00.000Z', - status: 'paused', + completionDate: '2018-05-16T00:00:00.000Z', + status: 'active', type: 'type1', details: { detail1: { @@ -1198,28 +1198,10 @@ describe('UPDATE Milestone', () => { createdAt: '2018-05-11T00:00:00.000Z', updatedAt: '2018-05-11T00:00:00.000Z', }, - ]).then(() => models.StatusHistory.bulkCreate([ - { - reference: 'milestone', - referenceId: 7, - status: 'active', - comment: 'comment', - createdBy: 1, - createdAt: '2018-05-15T00:00:00Z', - updatedBy: 1, - updatedAt: '2018-05-15T00:00:00Z', - }, - { - reference: 'milestone', - referenceId: 7, - status: 'paused', - comment: 'comment', - createdBy: 1, - createdAt: '2018-05-16T00:00:00Z', - updatedBy: 1, - updatedAt: '2018-05-16T00:00:00Z', - }, - ]).then(() => { + ]).then(() => models.Milestone.findById(7) + // pause milestone before resume + .then(milestone => milestone.update(_.assign({}, milestone.toJSON(), { status: 'paused' }))), + ).then(() => { request(server) .patch('/v4/timelines/1/milestones/7') .set({ @@ -1245,11 +1227,11 @@ describe('UPDATE Milestone', () => { }).then((statusHistories) => { statusHistories.length.should.be.eql(1); done(); - }); - }); + }).catch(done); + }).catch(done); } }); - })); + }); }); describe('Bus api', () => { diff --git a/src/routes/timelines/list.spec.js b/src/routes/timelines/list.spec.js index 4f68c843..9da02b3b 100644 --- a/src/routes/timelines/list.spec.js +++ b/src/routes/timelines/list.spec.js @@ -182,25 +182,31 @@ describe('LIST timelines', () => { ])) .then(() => // Create timelines - Promise.all([ - models.Timeline.bulkCreate(timelines, { returning: true }), - models.Milestone.bulkCreate(milestones), - ])) - .then(([createdTimelines, mappedMilestones]) => + models.Timeline.bulkCreate(timelines, { returning: true }) + .then(createdTimelines => ( + // create milestones after timelines + models.Milestone.bulkCreate(milestones)) + .then(createdMilestones => [createdTimelines, createdMilestones]), + ), + ).then(([createdTimelines, createdMilestones]) => // Index to ES - Promise.all(_.map(createdTimelines, (createdTimeline) => { - const timelineJson = _.omit(createdTimeline.toJSON(), 'deletedAt', 'deletedBy'); - timelineJson.projectId = createdTimeline.id !== 3 ? 1 : 2; - if (timelineJson.id === 1) { - timelineJson.milestones = mappedMilestones; - } - return server.services.es.index({ - index: ES_TIMELINE_INDEX, - type: ES_TIMELINE_TYPE, - id: timelineJson.id, - body: timelineJson, - }); - })) + Promise.all(_.map(createdTimelines, (createdTimeline) => { + const timelineJson = _.omit(createdTimeline.toJSON(), 'deletedAt', 'deletedBy'); + timelineJson.projectId = createdTimeline.id !== 3 ? 1 : 2; + if (timelineJson.id === 1) { + timelineJson.milestones = _.map( + createdMilestones, + cm => _.omit(cm.toJSON(), 'deletedAt', 'deletedBy'), + ); + } + + return server.services.es.index({ + index: ES_TIMELINE_INDEX, + type: ES_TIMELINE_TYPE, + id: timelineJson.id, + body: timelineJson, + }); + })) .then(() => { // sleep for some time, let elasticsearch indices be settled sleep.sleep(5); From 15019dfd1d7cacd1e32aa555e0265c766c2ef11e Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Fri, 28 Jun 2019 11:01:47 +0800 Subject: [PATCH 9/9] removed special non-RESTful pause/resume milestone endpoints --- postman.json | 118 +-------------------------------------------------- 1 file changed, 1 insertion(+), 117 deletions(-) diff --git a/postman.json b/postman.json index 32e7fe88..17ee5a26 100644 --- a/postman.json +++ b/postman.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "57206894-511c-4ffb-94bb-e50d2dd416fb", + "_postman_id": "efae2a9b-b869-4965-b889-3278870b29ea", "name": "tc-project-service", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -1006,10 +1006,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/db", "host": [ @@ -1035,10 +1031,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/db?limit=1&offset=1", "host": [ @@ -1074,10 +1066,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/db?filter=type%3Dgeneric", "host": [ @@ -1109,10 +1097,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/db?sort=type%20desc", "host": [ @@ -1144,10 +1128,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/db?fields=id,name,description", "host": [ @@ -1179,10 +1159,6 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/db", "host": [ @@ -2296,10 +2272,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases/db", "host": [ @@ -2330,10 +2302,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases/db?fields=status,name,budget", "host": [ @@ -2370,10 +2338,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases/db?sort=status desc", "host": [ @@ -2410,10 +2374,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases/db?sort=order desc", "host": [ @@ -2752,10 +2712,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases/1/products/db", "host": [ @@ -4528,78 +4484,6 @@ } }, "response": [] - }, - { - "name": "Pause Milestone", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - } - ], - "body": { - "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"comment\": \"Comment\"\r\n\t}\r\n}" - }, - "url": { - "raw": "{{api-url}}/v4/timelines/1/milestones/2/status/pause", - "host": [ - "{{api-url}}" - ], - "path": [ - "v4", - "timelines", - "1", - "milestones", - "2", - "status", - "pause" - ] - } - }, - "response": [] - }, - { - "name": "Resume Milestone", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - } - ], - "body": { - "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"comment\": \"Comment\"\r\n\t}\r\n}" - }, - "url": { - "raw": "{{api-url}}/v4/timelines/1/milestones/2/status/resume", - "host": [ - "{{api-url}}" - ], - "path": [ - "v4", - "timelines", - "1", - "milestones", - "2", - "status", - "resume" - ] - } - }, - "response": [] } ] },