diff --git a/.gitignore b/.gitignore index 37f3fd7a..6854f12f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ logs *.log npm-debug.log* .DS_Store +.tern-port +*# dist/ diff --git a/docs/Project API.postman_collection.json b/docs/Project API.postman_collection.json index 930185eb..b260e434 100644 --- a/docs/Project API.postman_collection.json +++ b/docs/Project API.postman_collection.json @@ -1,15 +1,17 @@ { "info": { - "_postman_id": "49981b6e-f611-4016-999e-737ef4103435", + "_postman_id": "c8bf6c3e-5f42-4967-834d-572251c341f6", "name": "Project API", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ { "name": "Project Attachments", + "description": null, "item": [ { "name": "bookmarks", + "description": null, "item": [ { "name": " Create project without bookmarks", @@ -115,6 +117,10 @@ "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}", "host": [ @@ -231,6 +237,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects", "host": [ @@ -244,7 +254,6 @@ "response": [] } ], - "protocolProfileBehavior": {}, "_postman_isSubFolder": true }, { @@ -365,9 +374,6 @@ }, { "name": "Download attachment", - "protocolProfileBehavior": { - "disableBodyPruning": true - }, "request": { "method": "GET", "header": [ @@ -402,9 +408,6 @@ }, { "name": "List attachments", - "protocolProfileBehavior": { - "disableBodyPruning": true - }, "request": { "method": "GET", "header": [ @@ -436,11 +439,11 @@ }, "response": [] } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Project With TemplateId issue", + "description": null, "item": [ { "name": "Create project with templateId (not existed)", @@ -517,11 +520,11 @@ }, "response": [] } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Project Members", + "description": null, "item": [ { "name": "Create project member with no payload", @@ -877,6 +880,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/members", "host": [ @@ -902,6 +909,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/members?role=customer", "host": [ @@ -925,9 +936,6 @@ }, { "name": "Get project member", - "protocolProfileBehavior": { - "disableBodyPruning": true - }, "request": { "method": "GET", "header": [ @@ -1027,11 +1035,11 @@ }, "response": [] } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Project Members Invites", + "description": null, "item": [ { "name": "Create project member with no payload", @@ -1103,9 +1111,6 @@ }, { "name": "Get project member invite", - "protocolProfileBehavior": { - "disableBodyPruning": true - }, "request": { "method": "GET", "header": [ @@ -1227,11 +1232,11 @@ ] } } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Projects", + "description": "Requests for all things projects.", "item": [ { "name": "Create project without payload", @@ -1382,6 +1387,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}", "host": [ @@ -1406,6 +1415,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}?fields=id,name,description,members.id,members.projectId", "host": [ @@ -1436,6 +1449,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects", "host": [ @@ -1459,6 +1476,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects?perPage=1&page=1", "host": [ @@ -1492,6 +1513,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects?type=generic", "host": [ @@ -1521,6 +1546,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects?id=1&id=2", "host": [ @@ -1554,6 +1583,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects?sort=type asc", "host": [ @@ -1583,6 +1616,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects?fields=id,name,description", "host": [ @@ -1612,6 +1649,10 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects", "host": [ @@ -2161,12 +2202,11 @@ }, "response": [] } - ], - "description": "Requests for all things projects.", - "protocolProfileBehavior": {} + ] }, { "name": "Workstream", + "description": "Requests for all things projects.", "item": [ { "name": "Create workstream without payload", @@ -2292,6 +2332,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}", "host": [ @@ -2318,6 +2362,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/workstreams", "host": [ @@ -2397,12 +2445,11 @@ }, "response": [] } - ], - "description": "Requests for all things projects.", - "protocolProfileBehavior": {} + ] }, { "name": "Work", + "description": "Requests for all things projects.", "item": [ { "name": "Create work without payload", @@ -2569,6 +2616,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works/{{workId}}", "host": [ @@ -2597,6 +2648,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works", "host": [ @@ -2624,6 +2679,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works?sort=startDate desc", "host": [ @@ -2657,6 +2716,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works?fields=status,name,budget", "host": [ @@ -2782,12 +2845,11 @@ }, "response": [] } - ], - "description": "Requests for all things projects.", - "protocolProfileBehavior": {} + ] }, { "name": "Work Item", + "description": "Requests for all things projects.", "item": [ { "name": "Create work item without payload", @@ -2923,6 +2985,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works/{{workId}}/workitems/{{itemId}}", "host": [ @@ -2953,6 +3019,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works/{{workId}}/workitems", "host": [ @@ -3044,12 +3114,11 @@ }, "response": [] } - ], - "description": "Requests for all things projects.", - "protocolProfileBehavior": {} + ] }, { "name": "Work Management Permission", + "description": "Requests for all things projects.", "item": [ { "name": "Create work management permission without payload", @@ -3223,6 +3292,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/workManagementPermission/{{workManagementPermissionId}}", "host": [ @@ -3249,6 +3322,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/workManagementPermission", "host": [ @@ -3274,6 +3351,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/workManagementPermission?filter=template", "host": [ @@ -3305,6 +3386,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/workManagementPermission?filter=invalid%3D2%26projectTemplateId%3D1", "host": [ @@ -3336,6 +3421,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/workManagementPermission?filter=projectTemplateId%3D1", "host": [ @@ -3421,12 +3510,11 @@ }, "response": [] } - ], - "description": "Requests for all things projects.", - "protocolProfileBehavior": {} + ] }, { "name": "Permissions", + "description": null, "item": [ { "name": "Get permissions - 404", @@ -3438,6 +3526,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/9999/permissions", "host": [ @@ -3531,6 +3623,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/permissions", "host": [ @@ -3556,6 +3652,10 @@ "value": "Bearer {{jwt-token-manager-40051334}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/permissions", "host": [ @@ -3593,11 +3693,11 @@ ] } } - ], - "protocolProfileBehavior": {} + ] }, { "name": "WorkManagementForTemplate", + "description": "Requests for all things projects.", "item": [ { "name": "Create workstream with valid values", @@ -3931,12 +4031,11 @@ }, "response": [] } - ], - "description": "Requests for all things projects.", - "protocolProfileBehavior": {} + ] }, { "name": "EventHandling and Integration with Direct Project API", + "description": null, "item": [ { "name": "mock direct projects", @@ -3952,6 +4051,10 @@ "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "https://api.topcoder-dev.com/v3/direct/projects", "protocol": "https", @@ -4182,11 +4285,11 @@ ] } } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Project Phase", + "description": null, "item": [ { "name": "Create Phase", @@ -4343,6 +4446,10 @@ "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/phases", "host": [ @@ -4371,6 +4478,10 @@ "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/phases?fields=status,name,budget", "host": [ @@ -4405,6 +4516,10 @@ "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/phases?sort=status desc", "host": [ @@ -4439,6 +4554,10 @@ "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/phases?sort=order desc", "host": [ @@ -4473,6 +4592,10 @@ "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/phases/{{phaseId}}", "host": [ @@ -4587,11 +4710,11 @@ }, "response": [] } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Phase Products", + "description": null, "item": [ { "name": "Create Phase Product", @@ -4652,6 +4775,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/phases/{{phaseId}}/products", "host": [ @@ -4678,6 +4805,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/phases/{{phaseId}}/products/{{phaseProductId}}", "host": [ @@ -4761,11 +4892,11 @@ }, "response": [] } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Project Templates", + "description": null, "item": [ { "name": "Create project template", @@ -4986,6 +5117,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/projectTemplates", "host": [ @@ -5014,6 +5149,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/projectTemplates/{{projectTemplateId}}", "host": [ @@ -5263,11 +5402,11 @@ }, "response": [] } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Product Templates", + "description": null, "item": [ { "name": "Create product template", @@ -5441,6 +5580,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/productTemplates", "host": [ @@ -5469,6 +5612,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/productTemplates/{{productTemplateId}}", "host": [ @@ -5658,11 +5805,11 @@ }, "response": [] } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Project Type", + "description": null, "item": [ { "name": "Create project type", @@ -5725,6 +5872,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/projectTypes", "host": [ @@ -5753,6 +5904,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/projectTypes/{{projectTypeId}}", "host": [ @@ -5834,11 +5989,11 @@ }, "response": [] } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Org Config", + "description": null, "item": [ { "name": "Create org config", @@ -5904,6 +6059,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/orgConfig", "host": [ @@ -5932,6 +6091,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/orgConfig?orgId={{orgStrId}}", "host": [ @@ -5967,6 +6130,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/orgConfig?orgId={{orgStrId}}&configName={{orgConfigName}}", "host": [ @@ -6080,11 +6247,11 @@ ] } } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Product Category", + "description": null, "item": [ { "name": "Create product category", @@ -6147,6 +6314,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/productCategories", "host": [ @@ -6175,6 +6346,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/productCategories/{{productCategoryId}}", "host": [ @@ -6288,11 +6463,11 @@ ] } } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Project upgrade", + "description": "Request to migrate projects.", "item": [ { "name": "Migrate project", @@ -6422,12 +6597,11 @@ }, "response": [] } - ], - "description": "Request to migrate projects.", - "protocolProfileBehavior": {} + ] }, { "name": "Timeline", + "description": null, "item": [ { "name": "Create timeline", @@ -6569,6 +6743,10 @@ "type": "text" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/timelines?reference=project&referenceId={{projectId}}", "host": [ @@ -6605,6 +6783,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/timelines/{{timelineId}}", "host": [ @@ -6632,6 +6814,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/timelines/{{timelineId}}", "host": [ @@ -6769,11 +6955,11 @@ }, "response": [] } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Milestone", + "description": null, "item": [ { "name": "Create milestone", @@ -6806,7 +6992,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"name\": \"milestone 3\",\r\n \"description\": \"description 3\",\r\n \"duration\": 4,\r\n \"startDate\": \"2018-05-29T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-30T00:00:00.000Z\",\r\n \"completionDate\": \"2018-05-31T00:00:00.000Z\",\r\n \"status\": \"open\",\r\n \"type\": \"type3\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 2,\r\n 3,\r\n 4\r\n ]\r\n },\r\n \"order\": 1,\r\n \"plannedText\": \"plannedText 3\",\r\n \"activeText\": \"activeText 3\",\r\n \"completedText\": \"completedText 3\",\r\n \"blockedText\": \"blockedText 3\"\r\n}" + "raw": "{\r\n \"name\": \"milestone 3\",\r\n \"description\": \"description 3\",\r\n \"duration\": 4,\r\n \"startDate\": \"2018-05-29T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-30T00:00:00.000Z\",\r\n \"completionDate\": \"2018-05-31T00:00:00.000Z\",\r\n \"status\": \"draft\",\r\n \"type\": \"type3\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 2,\r\n 3,\r\n 4\r\n ]\r\n },\r\n \"order\": 1,\r\n \"plannedText\": \"plannedText 3\",\r\n \"activeText\": \"activeText 3\",\r\n \"completedText\": \"completedText 3\",\r\n \"blockedText\": \"blockedText 3\"\r\n}" }, "url": { "raw": "{{api-url}}/timelines/{{timelineId}}/milestones", @@ -6869,6 +7055,10 @@ "type": "text" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/timelines/{{timelineId}}/milestones", "host": [ @@ -6897,6 +7087,10 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/timelines/{{timelineId}}/milestones?sort=order desc", "host": [ @@ -6931,6 +7125,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/timelines/{{timelineId}}/milestones/{{milestoneId}}", "host": [ @@ -6946,6 +7144,53 @@ }, "response": [] }, + { + "name": "Bulk update milestone", + "event": [ + { + "listen": "test", + "script": { + "id": "8fd1d5e9-8e6e-4cd7-9010-b855308be069", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"milestoneId\", pm.response.json().id);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "[{\r\n \"name\": \"milestone new\",\r\n \"description\": \"description new\",\r\n \"duration\": 4,\r\n \"startDate\": \"2018-05-29T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-30T00:00:00.000Z\",\r\n \"completionDate\": \"2018-05-31T00:00:00.000Z\",\r\n \"status\": \"draft\",\r\n \"type\": \"type3\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 2,\r\n 3,\r\n 4\r\n ]\r\n },\r\n \"order\": 1,\r\n \"plannedText\": \"plannedText 3\",\r\n \"activeText\": \"activeText 3\",\r\n \"completedText\": \"completedText 3\",\r\n \"blockedText\": \"blockedText 3\"\r\n},\r\n{\r\n\t\"id\": 2,\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"startDate\": \"2018-09-25T00:00:00.000Z\",\r\n \"completionDate\": \"2018-09-28T00:00:00.000Z\",\r\n \"status\": \"draft\",\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}}/timelines/{{timelineId}}/milestones", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}", + "milestones" + ] + } + }, + "response": [] + }, { "name": "Update milestone", "request": { @@ -7276,11 +7521,11 @@ }, "response": [] } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Milestone Template", + "description": null, "item": [ { "name": "Create milestone template", @@ -7506,6 +7751,10 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/timelines/metadata/milestoneTemplates", "host": [ @@ -7534,6 +7783,10 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/timelines/metadata/milestoneTemplates?reference=productTemplate&referenceId={{productTemplateId}}", "host": [ @@ -7572,6 +7825,10 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/timelines/metadata/milestoneTemplates?reference=productTemplate&referenceId={{productTemplateId}}&sort=order desc", "host": [ @@ -7614,6 +7871,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/{{milestoneTemplateId}}", "host": [ @@ -7860,11 +8121,11 @@ }, "response": [] } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Metadata", + "description": null, "item": [ { "name": "Get all metadata", @@ -7887,6 +8148,10 @@ "type": "text" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata", "host": [ @@ -7915,6 +8180,10 @@ }, "method": "GET", "header": [], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata?includeAllReferred=true", "host": [ @@ -7934,11 +8203,11 @@ }, "response": [] } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Form Version", + "description": null, "item": [ { "name": "List forms", @@ -7955,6 +8224,10 @@ }, "method": "GET", "header": [], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/form/{{formKey}}/versions", "host": [ @@ -7986,6 +8259,10 @@ }, "method": "GET", "header": [], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/form/{{formKey}}/versions/{{formVersion}}", "host": [ @@ -8018,6 +8295,10 @@ }, "method": "GET", "header": [], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/form/{{formKey}}", "host": [ @@ -8177,11 +8458,11 @@ }, "response": [] } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Form Revision", + "description": null, "item": [ { "name": "List all revision for version", @@ -8198,6 +8479,10 @@ }, "method": "GET", "header": [], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/form/{{formKey}}/versions/{{formVersion}}/revisions", "host": [ @@ -8231,6 +8516,10 @@ }, "method": "GET", "header": [], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/form/{{formKey}}/versions/{{formVersion}}/revisions/{{formRevision}}", "host": [ @@ -8398,11 +8687,11 @@ }, "response": [] } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Price Config Version", + "description": null, "item": [ { "name": "List price configs", @@ -8419,6 +8708,10 @@ }, "method": "GET", "header": [], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/priceConfig/dev/versions", "host": [ @@ -8450,6 +8743,10 @@ }, "method": "GET", "header": [], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/priceConfig/{{priceKey}}/versions/{{priceVersion}}", "host": [ @@ -8482,6 +8779,10 @@ }, "method": "GET", "header": [], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/priceConfig/{{priceKey}}", "host": [ @@ -8663,11 +8964,11 @@ ] } } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Price Config Revision", + "description": null, "item": [ { "name": "List all revision for version", @@ -8684,6 +8985,10 @@ }, "method": "GET", "header": [], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/priceConfig/dev/versions/3/revisions", "host": [ @@ -8717,6 +9022,10 @@ }, "method": "GET", "header": [], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/priceConfig/{{priceKey}}/versions/{{priceVersion}}/revisions/{{priceRevision}}", "host": [ @@ -8884,11 +9193,11 @@ }, "response": [] } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Plan Config Version", + "description": null, "item": [ { "name": "List plan configs", @@ -8905,6 +9214,10 @@ }, "method": "GET", "header": [], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/planConfig/dev/versions", "host": [ @@ -8936,6 +9249,10 @@ }, "method": "GET", "header": [], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/planConfig/{{planKey}}/versions/{{planVersion}}", "host": [ @@ -8968,6 +9285,10 @@ }, "method": "GET", "header": [], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/planConfig/{{planKey}}", "host": [ @@ -9127,11 +9448,11 @@ }, "response": [] } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Plan Config Revision", + "description": null, "item": [ { "name": "List all revision for version", @@ -9148,6 +9469,10 @@ }, "method": "GET", "header": [], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/planConfig/{{planKey}}/versions/{{planVersion}}/revisions", "host": [ @@ -9181,6 +9506,10 @@ }, "method": "GET", "header": [], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/metadata/planConfig/{{planKey}}/versions/{{planVersion}}/revisions/{{planRevision}}", "host": [ @@ -9348,14 +9677,15 @@ }, "response": [] } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Project Reports", + "description": null, "item": [ { "name": "summary", + "description": null, "item": [ { "name": "get report by admin", @@ -9368,6 +9698,10 @@ "type": "text" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/reports?reportName=summary", "host": [ @@ -9399,6 +9733,10 @@ "type": "text" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/reports?reportName=summary", "host": [ @@ -9430,6 +9768,10 @@ "value": "Bearer {{jwt-token-admin-40051333}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/123456/reports?reportName=summary", "host": [ @@ -9461,6 +9803,10 @@ "value": "Bearer {{jwt-token-admin-40051333}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/reports?reportName=summary123", "host": [ @@ -9482,11 +9828,11 @@ "response": [] } ], - "protocolProfileBehavior": {}, "_postman_isSubFolder": true }, { "name": "projectBudget", + "description": null, "item": [ { "name": "get report by admin", @@ -9499,6 +9845,10 @@ "value": "Bearer {{jwt-token-admin-40051333}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/reports?reportName=projectBudget", "host": [ @@ -9520,14 +9870,13 @@ "response": [] } ], - "protocolProfileBehavior": {}, "_postman_isSubFolder": true } - ], - "protocolProfileBehavior": {} + ] }, { "name": "Project Setting", + "description": null, "item": [ { "name": "Create project setting - double", @@ -9970,6 +10319,10 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/settings", "host": [ @@ -9998,6 +10351,10 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/settings", "host": [ @@ -10026,6 +10383,10 @@ "value": "Bearer {{jwt-token-manager-40051334}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/settings", "host": [ @@ -10238,9 +10599,7 @@ }, "response": [] } - ], - "protocolProfileBehavior": {} + ] } - ], - "protocolProfileBehavior": {} + ] } \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 6a975a85..1eef5e41 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2890,7 +2890,7 @@ paths: example: 'work.create': true 'workItem.edit': true - + '401': description: Unauthorized schema: @@ -3178,6 +3178,50 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/ErrorModel' + patch: + tags: + - milestone + operationId: batchUpdateMilestone + security: + - Bearer: [] + description: >- + Update a batch of milestones. All users who can edit the timeline can access this + endpoint. For attributes with JSON object type, it would overwrite the + existing fields, or add new if the fields don't exist in the JSON + object. + parameters: + - in: body + name: body + required: true + schema: + type: array + items: + $ref: '#/definitions/Milestone' + responses: + '200': + description: Aggregation of bulk operations + schema: + $ref: '#/definitions/BulkMilestoneUpdateResponse' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '400': + description: Bad request + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' '/timelines/{timelineId}/milestones/{milestoneId}': parameters: - $ref: '#/parameters/timelineIdParam' @@ -3257,7 +3301,9 @@ paths: in: body required: true schema: - $ref: '#/definitions/MilestonePatchRequest' + type: array + items: + $ref: '#/definitions/MilestonePatchRequest' delete: tags: - milestone @@ -5968,6 +6014,32 @@ definitions: description: READ-ONLY. User that last updated this object readOnly: true - $ref: '#/definitions/MilestonePostRequest' + BulkMilestoneUpdateResponse: + title: Bulk milestone update response object + type: object + required: + - created + - deleted + - updated + properties: + created: + description: The created milestones + type: array + items: + $ref: '#/definitions/Milestone' + updated: + description: The updated milestones + type: array + items: + $ref: '#/definitions/Milestone' + deleted: + description: The deleted milestones + type: array + items: + type: object + properties: + id: + type: string MilestoneTemplateRequest: title: Milestone template request object type: object diff --git a/package.json b/package.json index e1ccee32..2a1472ba 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "express-list-routes": "^0.1.4", "express-request-id": "^1.1.0", "express-sanitizer": "^1.0.2", - "express-validation": "^0.6.0", + "express-validation": "^1.0.3", "handlebars": "^4.5.3", "http-aws-es": "^4.0.0", "joi": "^8.0.5", diff --git a/src/events/index.js b/src/events/index.js index c4c121b8..578e455f 100644 --- a/src/events/index.js +++ b/src/events/index.js @@ -9,8 +9,6 @@ import { timelineAdjustedKafkaHandler, } from './timelines'; import { - milestoneAddedHandler, - milestoneUpdatedHandler, milestoneUpdatedKafkaHandler, } from './milestones'; @@ -65,9 +63,9 @@ export const rabbitHandlers = { [EVENT.ROUTING_KEY.TIMELINE_ADDED]: voidRabbitHandler, // DISABLED [EVENT.ROUTING_KEY.TIMELINE_REMOVED]: voidRabbitHandler, // DISABLED [EVENT.ROUTING_KEY.TIMELINE_UPDATED]: voidRabbitHandler, // DISABLED - [EVENT.ROUTING_KEY.MILESTONE_ADDED]: milestoneAddedHandler, // index in ES because of cascade updates + [EVENT.ROUTING_KEY.MILESTONE_ADDED]: voidRabbitHandler, // DISABLED [EVENT.ROUTING_KEY.MILESTONE_REMOVED]: voidRabbitHandler, // DISABLED - [EVENT.ROUTING_KEY.MILESTONE_UPDATED]: milestoneUpdatedHandler, // index in ES because of cascade updates + [EVENT.ROUTING_KEY.MILESTONE_UPDATED]: voidRabbitHandler, // DISABLED }; export const kafkaHandlers = { diff --git a/src/permissions/index.js b/src/permissions/index.js index 52149622..422f621f 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -80,6 +80,7 @@ module.exports = () => { Authorizer.setPolicy('milestone.create', projectEdit); Authorizer.setPolicy('milestone.edit', projectEdit); Authorizer.setPolicy('milestone.delete', projectEdit); + Authorizer.setPolicy('milestone.bulkUpdate', projectEdit); Authorizer.setPolicy('milestone.view', projectView); Authorizer.setPolicy('metadata.list', true); // anyone can view all metadata diff --git a/src/routes/index.js b/src/routes/index.js index 361e4738..b2af8ef7 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -210,6 +210,7 @@ router.route('/v5/timelines/:timelineId(\\d+)') router.route('/v5/timelines/:timelineId(\\d+)/milestones') .post(require('./milestones/create')) + .patch(require('./milestones/bulkUpdate')) .get(require('./milestones/list')); router.route('/v5/timelines/:timelineId(\\d+)/milestones/:milestoneId(\\d+)') diff --git a/src/routes/milestones/bulkUpdate.js b/src/routes/milestones/bulkUpdate.js new file mode 100644 index 00000000..8144c841 --- /dev/null +++ b/src/routes/milestones/bulkUpdate.js @@ -0,0 +1,108 @@ +/** + * Bulk create/update/delete milestones + */ +import Promise from 'bluebird'; +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import validateTimeline from '../../middlewares/validateTimeline'; +import { EVENT, RESOURCES } from '../../constants'; +import models from '../../models'; +import { createMilestone, deleteMilestone, updateMilestone } from './commonHelper'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + timelineId: Joi.number().integer().positive().required(), + }, + body: Joi.array().items(Joi.object().keys({ + id: Joi.number().integer().positive(), + name: Joi.string().max(255).optional(), + description: Joi.string().max(255), + duration: Joi.number().integer().min(1).optional(), + startDate: Joi.date().required(), + actualStartDate: Joi.date().allow(null), + endDate: Joi.date().min(Joi.ref('startDate')).allow(null), + completionDate: Joi.date().min(Joi.ref('startDate')).allow(null), + status: Joi.string().max(45).optional(), + type: Joi.string().max(45).optional(), + details: Joi.object(), + order: Joi.number().integer().optional(), + plannedText: Joi.string().max(512).optional(), + activeText: Joi.string().max(512).optional(), + 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(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + })).required(), + options: { + contextRequest: true, + }, +}; + +module.exports = [ + validate(schema), + validateTimeline.validateTimelineIdParam, + permissions('milestone.bulkUpdate'), + (req, res, next) => models.sequelize.transaction(async (transaction) => { + const timelineId = req.params.timelineId; + const where = { timelineId }; + const { toKeep, toCreate } = req.body.reduce( + (acc, item) => { + if ({}.hasOwnProperty.call(item, 'id')) { + acc.toKeep.set(item.id, item); + } else { + acc.toCreate.push(item); + } + return acc; + }, + { toKeep: new Map(), toCreate: [] }); + const existing = await models.Milestone.findAll({ where }, { transaction }); + const { toUpdate, toDelete } = existing.reduce( + (acc, item) => { + if (toKeep.has(item.id)) { + acc.toUpdate.push([item, toKeep.get(item.id)]); + } else { + acc.toDelete.push(item); + } + return acc; + }, + { toDelete: [], toUpdate: [] }); + if (toUpdate.length < toKeep.size) { + const existingIds = new Set(existing.map(item => item.id)); + toKeep.forEach((v, id) => { + if (!existingIds.has(id)) { + const apiErr = new Error(`Milestone not found for milestone id ${id}`); + apiErr.status = 404; + throw apiErr; + } + }); + } + const created = await Promise.mapSeries( + toCreate, data => createMilestone(req.authUser, req.timeline, data, transaction)); + const deleted = await Promise.mapSeries( + toDelete, item => deleteMilestone(req.authUser, timelineId, item.id, transaction, item)); + const updated = await Promise.mapSeries( + toUpdate, ([item, data]) => updateMilestone(req.authUser, timelineId, data, transaction, item)); + return { created, deleted, updated }; + }) + .then(({ created, deleted, updated }) => { + [ + [created, EVENT.ROUTING_KEY.MILESTONE_ADDED], + [deleted, EVENT.ROUTING_KEY.MILESTONE_REMOVED], + [updated, EVENT.ROUTING_KEY.MILESTONE_UPDATED], + ].forEach(([results, routingKey]) => + results.forEach(result => util.sendResourceToKafkaBus( + req, routingKey, RESOURCES.MILESTONE, result))); + res.json({ created, deleted, updated }); + }) + .catch(next), +]; diff --git a/src/routes/milestones/bulkUpdate.spec.js b/src/routes/milestones/bulkUpdate.spec.js new file mode 100644 index 00000000..a54f3560 --- /dev/null +++ b/src/routes/milestones/bulkUpdate.spec.js @@ -0,0 +1,487 @@ +/* eslint-disable no-unused-expressions */ +/** + * Tests for bulkUpdate + */ +import _ from 'lodash'; +import chai from 'chai'; +import sinon from 'sinon'; +import request from 'supertest'; +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import busApi from '../../services/busApi'; +import { RESOURCES, BUS_API_EVENT } from '../../constants'; + +const should = chai.should(); + +describe('BULK UPDATE Milestones', () => { + 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: 'active', + 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: 'reviewed', + 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: 'active', + 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: 'active', + 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: 'active', + 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: 1, + name: 'Milestone 6', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'active', + 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((done) => { + testUtil.clearDb(done); + }); + + describe('PATCH /timelines/{timelineId}/milestones', () => { + const body = { + name: 'Milestone 1', + duration: 3, + description: 'description-updated', + status: 'draft', + type: 'type1-updated', + startDate: '2018-05-14T00:00:00.000Z', + details: { + detail1: { + subDetail1A: 0, + subDetail1C: 3, + }, + detail2: [4], + detail3: 3, + }, + order: 1, + plannedText: 'plannedText 1-updated', + activeText: 'activeText 1-updated', + completedText: 'completedText 1-updated', + blockedText: 'blockedText 1-updated', + hidden: true, + }; + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch('/v5/timelines/1/milestones') + .send([body]) + .expect(403, done); + }); + + it('should return 403 for member who is not in the project', (done) => { + request(server) + .patch('/v5/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send([body]) + .expect(403, done); + }); + + it('should return 403 for non-admin member updating the completionDate', (done) => { + const newBody = _.cloneDeep(body); + newBody.id = 1; + newBody.completionDate = '2019-01-16T00:00:00.000Z'; + request(server) + .patch('/v5/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send([newBody]) + .expect(403, done); + }); + + it('should return 403 for non-admin member updating the actualStartDate', (done) => { + const newBody = _.cloneDeep(body); + newBody.actualStartDate = '2018-05-15T00:00:00.000Z'; + newBody.id = 1; + request(server) + .patch('/v5/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send([newBody]) + .expect(403, done); + }); + + it('should return 404 for non-existed timeline', (done) => { + request(server) + .patch('/v5/timelines/1234/milestones') + .send([body]) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted timeline', (done) => { + request(server) + .patch('/v5/timelines/3/milestones') + .send([body]) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for non-existed Milestone', (done) => { + const data = [Object.assign({}, body, { id: 111 })]; + request(server) + .patch('/v5/timelines/1/milestones') + .send(data) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted Milestone', (done) => { + const data = [Object.assign({}, body, { id: 5 })]; + request(server) + .patch('/v5/timelines/1/milestones') + .send(data) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 400 for invalid timelineId param', (done) => { + request(server) + .patch('/v5/timelines/0/milestones') + .send([body]) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(400, done); + }); + + it('should return 400 for invalid milestoneId param', (done) => { + const data = [Object.assign({}, body, { id: 0 })]; + request(server) + .patch('/v5/timelines/1/milestones') + .send(data) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(400, done); + }); + + it('should return 200 for admin doing creation, update, deletion operation', (done) => { + const data = [Object.assign({}, body, { id: 1, actualStartDate: '2018-05-15T00:00:00.000Z' }), + Object.assign({}, body)]; + request(server) + .patch('/v5/timelines/1/milestones') + .send(data) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + should.exist(res.body.created); + should.exist(res.body.deleted); + should.exist(res.body.updated); + const { created, deleted, updated } = res.body; + created.length.should.be.eql(1); + should.exist(created[0].id); + created[0].name.should.be.eql(body.name); + deleted.length.should.be.eql(4); + deleted[0].id.should.be.eql(2); + deleted[1].id.should.be.eql(3); + deleted[2].id.should.be.eql(4); + deleted[3].id.should.be.eql(6); + updated.length.should.be.eql(1); + updated[0].id.should.be.equal(1); + updated[0].actualStartDate.should.be.equal('2018-05-15T00:00:00.000Z'); + 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('sends send correct BUS API messages when milestone are bulk updated', (done) => { + const data = [Object.assign({}, body, { id: 1, actualStartDate: '2018-05-15T00:00:00.000Z' }), + Object.assign({}, body)]; + request(server) + .patch('/v5/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(data) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, sinon.match({ + resource: RESOURCES.MILESTONE, + id: 1, + })).should.be.true; + + createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_ADDED, sinon.match({ + resource: RESOURCES.MILESTONE, + name: body.name, + })).should.be.true; + + createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_REMOVED, sinon.match({ + resource: RESOURCES.MILESTONE, + id: 2, + })).should.be.true; + createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_REMOVED, sinon.match({ + resource: RESOURCES.MILESTONE, + id: 3, + })).should.be.true; + createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_REMOVED, sinon.match({ + resource: RESOURCES.MILESTONE, + id: 4, + })).should.be.true; + createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_REMOVED, sinon.match({ + resource: RESOURCES.MILESTONE, + id: 6, + })).should.be.true; + done(); + }); + } + }); + }); + }); + }); +}); diff --git a/src/routes/milestones/commonHelper.js b/src/routes/milestones/commonHelper.js new file mode 100644 index 00000000..52d7c2ce --- /dev/null +++ b/src/routes/milestones/commonHelper.js @@ -0,0 +1,191 @@ +/** + * Common functionality for milestone endpoints + */ +import _ from 'lodash'; +import moment from 'moment'; +import config from 'config'; +import models from '../../models'; +import { MILESTONE_STATUS, ADMIN_ROLES } from '../../constants'; +import util from '../../util'; + +const validStatuses = JSON.parse(config.get('VALID_STATUSES_BEFORE_PAUSED')); + +/** + * Create new milestone + * @param {Object} authUser The current user + * @param {Object} timeline The timeline of milestone + * @param {Object} data The updated data + * @param {Object} transaction The transaction to use + * @returns {Objec} The updated milestone + * @throws {Error} If something went wrong + */ +async function createMilestone(authUser, timeline, data, transaction) { + // eslint-disable-next-line + const userId = authUser.userId; + const entity = Object.assign({}, data, { createdBy: userId, updatedBy: userId, timelineId: timeline.id }); + if (entity.startDate < timeline.startDate) { + const apiErr = new Error('Milestone startDate must not be before the timeline startDate'); + apiErr.status = 400; + throw apiErr; + } + // Circumvent postgresql duplicate key error, see https://stackoverflow.com/questions/50834623/sequelizejs-error-duplicate-key-value-violates-unique-constraint-message-pkey + await models.sequelize.query('SELECT setval(\'milestones_id_seq\', (SELECT MAX(id) FROM "milestones"))', + { raw: true, transaction }); + const result = await models.Milestone.create(entity, { transaction }); + return _.omit(result.toJSON(), ['deletedBy', 'deletedAt']); +} + +/** + * Delete single milestone + * @param {Object} authUser The current user + * @param {String|Number} timelineId The timeline id of milestone + * @param {String|Number} id The milestone id + * @param {Object} transaction The transaction to use + * @param {Object} [item] The milestone to delete + * @returns {Object} Object with id field for milestone id + * @throws {Error} If something went wrong + */ +async function deleteMilestone(authUser, timelineId, id, transaction, item) { + const where = { id, timelineId }; + const milestone = item || await models.Milestone.findOne({ where }, { transaction }); + if (!milestone) { + const apiErr = new Error(`Milestone not found for milestone id ${id}`); + apiErr.status = 404; + throw apiErr; + } + await milestone.update({ deletedBy: authUser.userId }, { transaction }); + await milestone.destroy({ transaction }); + return { id }; +} + +/** + * Update single milestone + * @param {Object} authUser The current user + * @param {String|Number} timelineId The timeline id of milestone + * @param {Object} data The updated data + * @param {Object} transaction The transaction to use + * @param {Object} [item] The item to update + * @returns {Object} The updated milestone + * @throws {Error} If something went wrong + */ +async function updateMilestone(authUser, timelineId, data, transaction, item) { + const id = data.id; + const where = { + timelineId, + id, + }; + const entityToUpdate = Object.assign({}, data, { + updatedBy: authUser.userId, + timelineId, + }); + + delete entityToUpdate.id; + + const milestone = item || await models.Milestone.findOne({ where }, { transaction }); + if (!milestone) { + const apiErr = new Error(`Milestone not found for milestone id ${id}`); + apiErr.status = 404; + throw apiErr; + } + 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 = 400; + throw apiErr; + } + if (entityToUpdate.status === 'resume') { + if (milestone.status !== MILESTONE_STATUS.PAUSED) { + const apiErr = new Error('Milestone status isn\'t paused'); + apiErr.status = 400; + throw apiErr; + } + const statusHistory = await models.StatusHistory.findAll({ + where: { referenceId: id }, + order: [['createdAt', 'desc'], ['id', 'desc']], + attributes: ['status', 'id'], + limit: 2, + raw: true, + }, { transaction }); + if (statusHistory.length !== 2) { + const apiErr = new Error('No previous status is found'); + apiErr.status = 500; + throw apiErr; + } + entityToUpdate.status = statusHistory[1].status; + } + if ((entityToUpdate.completionDate || entityToUpdate.actualStartDate) && + !util.hasPermission({ topcoderRoles: ADMIN_ROLES }, authUser)) { + const apiErr = new Error('You are not authorised to perform this action'); + apiErr.status = 403; + throw apiErr; + } + + if ( + entityToUpdate.completionDate && + (entityToUpdate.actualStartDate || milestone.actualStartDate) && + moment.utc(entityToUpdate.completionDate).isBefore( + moment.utc(entityToUpdate.actualStartDate || milestone.actualStartDate), + 'day', + ) + ) { + const apiErr = new Error('The milestone completionDate should be greater or equal to actualStartDate.'); + apiErr.status = 400; + throw apiErr; + } + const durationChanged = {}.hasOwnProperty.call(entityToUpdate, 'duration') && + entityToUpdate.duration !== milestone.duration; + const statusChanged = {}.hasOwnProperty.call(entityToUpdate, 'status') && + entityToUpdate.status !== milestone.status; + const completionDateChanged = {}.hasOwnProperty.call(entityToUpdate, 'completionDate') && + !_.isEqual(milestone.completionDate, entityToUpdate.completionDate); + const today = moment + .utc() + .hours(0) + .minutes(0) + .seconds(0) + .milliseconds(0); + + entityToUpdate.details = util.mergeJsonObjects(milestone.details, entityToUpdate.details); + let actualStartDateChanged = false; + if (statusChanged) { + switch (entityToUpdate.status) { + case MILESTONE_STATUS.COMPLETED: + entityToUpdate.completionDate = entityToUpdate.completionDate || today; + entityToUpdate.duration = moment.utc(entityToUpdate.completionDate) + .diff(entityToUpdate.actualStartDate, 'days') + 1; + break; + case MILESTONE_STATUS.ACTIVE: + entityToUpdate.actualStartDate = today; + actualStartDateChanged = true; + break; + default: + } + } + // Updates the end date of the milestone if: + // 1. if duration of the milestone is udpated, update its end date + // OR + // 2. if actual start date is updated, updating the end date of the activated milestone because + // early or late start of milestone, we are essentially changing the end schedule of the milestone + if (durationChanged || actualStartDateChanged) { + const updatedStartDate = actualStartDateChanged ? entityToUpdate.actualStartDate : milestone.startDate; + const updatedDuration = _.get(entityToUpdate, 'duration', milestone.duration); + entityToUpdate.endDate = moment.utc(updatedStartDate).add(updatedDuration - 1, 'days').toDate(); + } + + // if completionDate has changed + if (!statusChanged && completionDateChanged) { + entityToUpdate.duration = moment.utc(entityToUpdate.completionDate) + .diff(entityToUpdate.actualStartDate, 'days') + 1; + entityToUpdate.status = MILESTONE_STATUS.COMPLETED; + } + + const result = await milestone.update(entityToUpdate, { comment: entityToUpdate.statusComment, transaction }); + return _.omit(result.toJSON(), ['deletedBy', 'deletedAt']); +} + + +module.exports = { + createMilestone, + deleteMilestone, + updateMilestone, +}; diff --git a/src/routes/milestones/create.js b/src/routes/milestones/create.js index 08cda372..131dfb08 100644 --- a/src/routes/milestones/create.js +++ b/src/routes/milestones/create.js @@ -2,14 +2,13 @@ * API to add a milestone */ import validate from 'express-validation'; -import _ from 'lodash'; import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; -import Sequelize from 'sequelize'; import util from '../../util'; import validateTimeline from '../../middlewares/validateTimeline'; import models from '../../models'; import { EVENT, RESOURCES } from '../../constants'; +import { createMilestone } from './commonHelper'; const permissions = tcMiddleware.permissions; @@ -50,98 +49,15 @@ module.exports = [ // for checking by the permissions middleware validateTimeline.validateTimelineIdParam, permissions('milestone.create'), - (req, res, next) => { - const entity = _.assign(req.body, { - createdBy: req.authUser.userId, - updatedBy: req.authUser.userId, - timelineId: req.params.timelineId, - }); - let result; - - // Validate startDate is not earlier than timeline startDate - let error; - if (req.body.startDate < req.timeline.startDate) { - error = 'Milestone startDate must not be before the timeline startDate'; - } - if (error) { - const apiErr = new Error(error); - apiErr.status = 400; - return next(apiErr); - } - - return models.sequelize.transaction(() => - // Save to DB - models.Milestone.create(entity) - .then((createdEntity) => { - // Omit deletedAt, deletedBy - result = _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'); - - // Increase the order of the other milestones in the same timeline, - // which have `order` >= this milestone order - return models.Milestone.update({ order: Sequelize.literal('"order" + 1') }, { - where: { - timelineId: result.timelineId, - id: { $ne: result.id }, - order: { $gte: result.order }, - }, - }); - }) - .then((updatedCount) => { - if (updatedCount) { - return models.Milestone.findAll({ - where: { - timelineId: result.timelineId, - id: { $ne: result.id }, - order: { $gte: result.order + 1 }, - }, - order: [['updatedAt', 'DESC']], - limit: updatedCount[0], - }); - } - return Promise.resolve(); - }), - ) - .then((otherUpdated) => { - // Send event to bus - req.log.debug('Sending event to RabbitMQ bus for milestone %d', result.id); - req.app.services.pubsub.publish(EVENT.ROUTING_KEY.MILESTONE_ADDED, - result, - { correlationId: req.id }, - ); - - // NOTE So far this logic is implemented in RabbitMQ handler of MILESTONE_ADDED - // Even though we send this event to the Kafka, the "project-processor-es" shouldn't process it. - util.sendResourceToKafkaBus( - req, - EVENT.ROUTING_KEY.MILESTONE_ADDED, - RESOURCES.MILESTONE, - result); - - // NOTE So far this logic is implemented in RabbitMQ handler of MILESTONE_ADDED - // Even though we send these events to the Kafka, the "project-processor-es" shouldn't process them. - // - // We don't process these event in "project-processor-es" - // because it will make 'version conflict' error in ES. - // The order of the other milestones need to be updated in the PROJECT_PHASE_UPDATED event handler - _.map(otherUpdated, milestone => + (req, res, next) => + models.sequelize.transaction(t => createMilestone(req.authUser, req.timeline, req.body, t)) + .then((result) => { util.sendResourceToKafkaBus( req, - EVENT.ROUTING_KEY.MILESTONE_UPDATED, + EVENT.ROUTING_KEY.MILESTONE_ADDED, RESOURCES.MILESTONE, - _.assign(_.pick(milestone.toJSON(), 'id', 'order', 'updatedBy', 'updatedAt')), - // Pass the same object as original milestone even though, their time has changed. - // So far we don't use time properties in the handler so it's ok. But in general, we should pass - // the original milestones. <- TODO - _.assign(_.pick(milestone.toJSON(), 'id', 'order', 'updatedBy', 'updatedAt')), - null, // no route - true, // don't send event to Notification Service as the main event here is updating one milestone - ), - ); - - // Write to the response - res.status(201).json(result); - return Promise.resolve(); - }) - .catch(next); - }, + result); + res.status(201).json(result); + }) + .catch(next), ]; diff --git a/src/routes/milestones/create.spec.js b/src/routes/milestones/create.spec.js index 271e1d2f..8e72736f 100644 --- a/src/routes/milestones/create.spec.js +++ b/src/routes/milestones/create.spec.js @@ -10,7 +10,7 @@ import server from '../../app'; import testUtil from '../../tests/util'; import models from '../../models'; import busApi from '../../services/busApi'; -import { EVENT, RESOURCES, BUS_API_EVENT, CONNECT_NOTIFICATION_EVENT } from '../../constants'; +import { RESOURCES, BUS_API_EVENT } from '../../constants'; const should = chai.should(); @@ -430,14 +430,7 @@ 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); - }); - - // eslint-disable-next-line no-unused-expressions - server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.MILESTONE_ADDED).should.be.true; + resJson.statusHistory.length.should.be.eql(0); // Verify 'order' of the other milestones models.Milestone.findAll({ where: { timelineId: 1 } }) @@ -446,9 +439,9 @@ describe('CREATE milestone', () => { if (milestone.id === 11) { milestone.order.should.be.eql(1); } else if (milestone.id === 12) { - milestone.order.should.be.eql(2 + 1); + milestone.order.should.be.eql(1 + 1); } else if (milestone.id === 13) { - milestone.order.should.be.eql(3 + 1); + milestone.order.should.be.eql(2 + 1); } }); @@ -556,7 +549,7 @@ describe('CREATE milestone', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.callCount.should.be.eql(4); + createEventSpy.callCount.should.be.eql(2); // added a new milestone createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_ADDED, sinon.match({ @@ -566,25 +559,6 @@ describe('CREATE milestone', () => { order: 2, })).should.be.true; - // as order of the next milestones after the added one have been updated, we send events about their update - createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, sinon.match({ - resource: RESOURCES.MILESTONE, - order: 3, - })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, sinon.match({ - resource: RESOURCES.MILESTONE, - order: 4, - })).should.be.true; - - // Check Notification Service events - createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.MILESTONE_ADDED, sinon.match({ - projectId: 1, - projectName: 'test1', - projectUrl: 'https://local.topcoder-dev.com/projects/1', - userId: 40051332, - initiatorUserId: 40051332, - })).should.be.true; - done(); }); } diff --git a/src/routes/milestones/delete.js b/src/routes/milestones/delete.js index ade643c7..b287fd53 100644 --- a/src/routes/milestones/delete.js +++ b/src/routes/milestones/delete.js @@ -8,6 +8,7 @@ import models from '../../models'; import util from '../../util'; import { EVENT, RESOURCES } from '../../constants'; import validateTimeline from '../../middlewares/validateTimeline'; +import { deleteMilestone } from './commonHelper'; const permissions = tcMiddleware.permissions; @@ -24,49 +25,17 @@ module.exports = [ // checking by the permissions middleware validateTimeline.validateTimelineIdParam, permissions('milestone.delete'), - (req, res, next) => { - const where = { - timelineId: req.params.timelineId, - id: req.params.milestoneId, - }; - - return models.sequelize.transaction(() => - // Find the milestone - models.Milestone.findOne({ - where, + (req, res, next) => + models + .sequelize + .transaction(t => deleteMilestone(req.authUser, req.params.timelineId, req.params.milestoneId, t)) + .then((deleted) => { + util.sendResourceToKafkaBus( + req, + EVENT.ROUTING_KEY.MILESTONE_REMOVED, + RESOURCES.MILESTONE, + deleted); + res.status(204).end(); }) - .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); - } - - // Update the deletedBy, and soft delete - return milestone.update({ deletedBy: req.authUser.userId }) - .then(() => milestone.destroy()); - }), - ) - .then((deleted) => { - // Send event to bus - req.log.debug('Sending event to RabbitMQ bus for milestone %d', deleted.id); - req.app.services.pubsub.publish(EVENT.ROUTING_KEY.MILESTONE_REMOVED, - deleted, - { correlationId: req.id }, - ); - - // emit the event - util.sendResourceToKafkaBus( - req, - EVENT.ROUTING_KEY.MILESTONE_REMOVED, - RESOURCES.MILESTONE, - { id: deleted.id }); - - // Write to response - res.status(204).end(); - return Promise.resolve(); - }) - .catch(next); - }, + .catch(next), ]; diff --git a/src/routes/milestones/delete.spec.js b/src/routes/milestones/delete.spec.js index a505e0d5..8fd62c0c 100644 --- a/src/routes/milestones/delete.spec.js +++ b/src/routes/milestones/delete.spec.js @@ -9,7 +9,7 @@ import chai from 'chai'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; -import { EVENT, RESOURCES, BUS_API_EVENT, CONNECT_NOTIFICATION_EVENT } from '../../constants'; +import { RESOURCES, BUS_API_EVENT } from '../../constants'; import busApi from '../../services/busApi'; const should = chai.should(); // eslint-disable-line no-unused-vars @@ -317,12 +317,7 @@ describe('DELETE milestone', () => { .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) - .expect(204) - .end(err => expectAfterDelete(1, 1, err, () => { - // eslint-disable-next-line no-unused-expressions - server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.MILESTONE_REMOVED).should.be.true; - done(); - })); + .expect(204, done); }); it('should return 204, for connect admin, if timeline was successfully removed', (done) => { @@ -401,15 +396,6 @@ describe('DELETE milestone', () => { id: 1, })).should.be.true; - // Check Notification Service events - createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.MILESTONE_REMOVED, sinon.match({ - projectId: 1, - projectName: 'test1', - projectUrl: 'https://local.topcoder-dev.com/projects/1', - userId: 40051332, - initiatorUserId: 40051332, - })).should.be.true; - done(); }); } diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index 95c06edd..17c8bda3 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -2,92 +2,16 @@ * API to update a milestone */ 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'; import util from '../../util'; import validateTimeline from '../../middlewares/validateTimeline'; -import { EVENT, RESOURCES, MILESTONE_STATUS, ADMIN_ROLES } from '../../constants'; +import { EVENT, RESOURCES } from '../../constants'; import models from '../../models'; +import { updateMilestone } from './commonHelper'; const permissions = tcMiddleware.permissions; -/** - * Cascades endDate/completionDate changes to all milestones with a greater order than the given one. - * @param {Object} origMilestone the original milestone that was updated - * @param {Object} updMilestone the milestone that was updated - * @returns {Promise} a promise that resolves to the last found milestone. If no milestone exists with an - * order greater than the passed updMilestone, the promise will resolve to the passed - * updMilestone - */ -function updateComingMilestones(origMilestone, updMilestone) { - // flag to indicate if the milestone in picture, is updated for completionDate field or not - const completionDateChanged = !_.isEqual(origMilestone.completionDate, updMilestone.completionDate); - const today = moment.utc().hours(0).minutes(0).seconds(0) - .milliseconds(0); - // updated milestone's start date, pefers actual start date over scheduled start date - const updMSStartDate = updMilestone.actualStartDate ? updMilestone.actualStartDate : updMilestone.startDate; - // calculates schedule end date for the milestone based on start date and duration - let updMilestoneEndDate = moment.utc(updMSStartDate).add(updMilestone.duration - 1, 'days').toDate(); - // if the milestone, in context, is completed, overrides the end date to the completion date - updMilestoneEndDate = updMilestone.completionDate ? updMilestone.completionDate : updMilestoneEndDate; - let originalMilestones; - return models.Milestone.findAll({ - where: { - timelineId: updMilestone.timelineId, - order: { $gt: updMilestone.order }, - }, - }).then((affectedMilestones) => { - originalMilestones = affectedMilestones.map(am => _.omit(am.toJSON(), 'deletedAt', 'deletedBy')); - const comingMilestones = _.sortBy(affectedMilestones, 'order'); - // calculates the schedule start date for the next milestone - let startDate = moment.utc(updMilestoneEndDate).add(1, 'days').toDate(); - let firstMilestoneFound = false; - const promises = _.map(comingMilestones, (_milestone) => { - const milestone = _milestone; - - // Update the milestone startDate if different than the iterated startDate - if (!_.isEqual(milestone.startDate, startDate)) { - milestone.startDate = startDate; - milestone.updatedBy = updMilestone.updatedBy; - } - - // Calculate the endDate, and update it if different - const endDate = moment.utc(milestone.startDate).add(milestone.duration - 1, 'days').toDate(); - if (!_.isEqual(milestone.endDate, endDate)) { - milestone.endDate = endDate; - milestone.updatedBy = updMilestone.updatedBy; - } - - // if completionDate is alerted, update status of the first non hidden milestone after the current one - if (!firstMilestoneFound && completionDateChanged && !milestone.hidden) { - // activate next milestone - milestone.status = MILESTONE_STATUS.ACTIVE; - milestone.actualStartDate = today; - firstMilestoneFound = true; - } - - // if milestone is not hidden, update the startDate for the next milestone, otherwise keep the same startDate for next milestone - if (!milestone.hidden) { - // Set the next startDate value to the next day after completionDate if present or the endDate - startDate = moment.utc(milestone.completionDate - ? milestone.completionDate - : milestone.endDate).add(1, 'days').toDate(); - } - return milestone.save(); - }); - - // Resolve promise with all original and updated milestones - return Promise.all(promises).then(updatedMilestones => ({ - originalMilestones, - updatedMilestones: updatedMilestones.map(um => _.omit(um.toJSON(), 'deletedAt', 'deletedBy')), - })); - }); -} - const schema = { params: { timelineId: Joi.number().integer().positive().required(), @@ -127,234 +51,21 @@ module.exports = [ // 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 = _.assign(req.body, { - updatedBy: req.authUser.userId, - timelineId: req.params.timelineId, - }); - - const timeline = req.timeline; - const originalTimeline = _.omit(timeline.toJSON(), 'deletedAt', 'deletedBy'); - - let original; - let updated; - - return models.sequelize.transaction(() => - // Find the milestone - models.Milestone.findOne({ where }) - .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 = 400; - 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 = 400; - return Promise.reject(apiErr); - } - const statusHistory = await models.StatusHistory.findAll({ - where: { referenceId: milestone.id }, - order: [['createdAt', 'desc'], ['id', 'desc']], - attributes: ['status', 'id'], - 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.actualStartDate) { - if (!util.hasPermission({ topcoderRoles: ADMIN_ROLES }, req.authUser)) { - const apiErr = new Error('You are not authorised to perform this action'); - apiErr.status = 403; - return Promise.reject(apiErr); - } - } - - if ( - entityToUpdate.completionDate && - (entityToUpdate.actualStartDate || milestone.actualStartDate) && - moment.utc(entityToUpdate.completionDate).isBefore( - moment.utc(entityToUpdate.actualStartDate || milestone.actualStartDate), - 'day', - ) - ) { - const apiErr = new Error('The milestone completionDate should be greater or equal to actualStartDate.'); - apiErr.status = 400; - return Promise.reject(apiErr); - } - - original = _.omit(milestone.toJSON(), ['deletedAt', 'deletedBy']); - const durationChanged = entityToUpdate.duration && entityToUpdate.duration !== milestone.duration; - const statusChanged = entityToUpdate.status && entityToUpdate.status !== milestone.status; - const completionDateChanged = entityToUpdate.completionDate - && !_.isEqual(milestone.completionDate, entityToUpdate.completionDate); - const today = moment.utc().hours(0).minutes(0).seconds(0) - .milliseconds(0); - - // Merge JSON fields - entityToUpdate.details = util.mergeJsonObjects(milestone.details, entityToUpdate.details); - - let actualStartDateCanged = false; - // if status has changed - if (statusChanged) { - // if status has changed to be completed, set the compeltionDate if not provided - if (entityToUpdate.status === MILESTONE_STATUS.COMPLETED) { - entityToUpdate.completionDate = entityToUpdate.completionDate ? entityToUpdate.completionDate : today; - entityToUpdate.duration = moment.utc(entityToUpdate.completionDate) - .diff(entityToUpdate.actualStartDate, 'days') + 1; - } - // if status has changed to be active, set the startDate to today - if (entityToUpdate.status === MILESTONE_STATUS.ACTIVE) { - // NOTE: not updating startDate as activating a milestone should not update the scheduled start date - // entityToUpdate.startDate = today; - // should update actual start date - entityToUpdate.actualStartDate = today; - actualStartDateCanged = true; - } - } - - // Updates the end date of the milestone if: - // 1. if duration of the milestone is udpated, update its end date - // OR - // 2. if actual start date is updated, updating the end date of the activated milestone because - // early or late start of milestone, we are essentially changing the end schedule of the milestone - if (durationChanged || actualStartDateCanged) { - const updatedStartDate = actualStartDateCanged ? entityToUpdate.actualStartDate : milestone.startDate; - const updatedDuration = _.get(entityToUpdate, 'duration', milestone.duration); - entityToUpdate.endDate = moment.utc(updatedStartDate).add(updatedDuration - 1, 'days').toDate(); - } - - // if completionDate has changed - if (!statusChanged && completionDateChanged) { - entityToUpdate.duration = moment.utc(entityToUpdate.completionDate) - .diff(entityToUpdate.actualStartDate, 'days') + 1; - entityToUpdate.status = MILESTONE_STATUS.COMPLETED; - } - - // Update - return milestone.update(entityToUpdate, { comment: entityToUpdate.statusComment }); - }) - .then((updatedMilestone) => { - // Omit deletedAt, deletedBy - updated = _.omit(updatedMilestone.toJSON(), 'deletedAt', 'deletedBy'); - - // Update order of the other milestones only if the order was changed - if (original.order === updated.order) { - return Promise.resolve(); - } - - return models.Milestone.count({ - where: { - timelineId: updated.timelineId, - id: { $ne: updated.id }, - order: updated.order, - }, - }) - .then((count) => { - if (count === 0) { - return Promise.resolve(); - } - - // Increase the order from M to K: if there is an item with order K, - // orders from M+1 to K should be made M to K-1 - if (original.order < updated.order) { - return models.Milestone.update({ order: Sequelize.literal('"order" - 1') }, { - where: { - timelineId: updated.timelineId, - id: { $ne: updated.id }, - order: { $between: [original.order + 1, updated.order] }, - }, - }); - } - - // Decrease the order from M to K: if there is an item with order K, - // orders from K to M-1 should be made K+1 to M - return models.Milestone.update({ order: Sequelize.literal('"order" + 1') }, { - where: { - timelineId: updated.timelineId, - id: { $ne: updated.id }, - order: { $between: [updated.order, original.order - 1] }, - }, - }); - }); - }) - .then(() => { - // we need to recalculate change in fields because we update some fields before making actual update - const needToCascade = !_.isEqual(original.completionDate, updated.completionDate) // completion date changed - || original.duration !== updated.duration // duration changed - || original.actualStartDate !== updated.actualStartDate; // actual start date updated - req.log.debug('needToCascade', needToCascade); - // Update dates of the other milestones only if cascade updates needed - if (needToCascade) { - return updateComingMilestones(original, updated) - .then(({ originalMilestones, updatedMilestones }) => { - // finds the last milestone updated - // if no milestone is updated by updateComingMilestones method, it means the current milestone is the last one - const lastTimelineMilestone = updatedMilestones.length ? _.last(updatedMilestones) : updated; - if (!_.isEqual(lastTimelineMilestone.endDate, timeline.endDate)) { - timeline.endDate = lastTimelineMilestone.endDate; - timeline.updatedBy = lastTimelineMilestone.updatedBy; - return timeline.save().then(() => ({ originalMilestones, updatedMilestones })); - } - return Promise.resolve({ originalMilestones, updatedMilestones }); - }); - } - return Promise.resolve({}); - }), - ) - .then(({ originalMilestones, updatedMilestones }) => { - const cascadedMilestones = _.map(originalMilestones, om => ({ - original: om, updated: _.find(updatedMilestones, um => um.id === om.id), - })); - const cascadedUpdates = { milestones: cascadedMilestones }; - // if there is a change in timeline, add it to the cascadedUpdates - if (originalTimeline.updatedAt !== timeline.updatedAt) { - cascadedUpdates.timeline = { - original: originalTimeline, - updated: _.omit(timeline.toJSON(), 'deletedAt', 'deletedBy'), - }; - } - // 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_UPDATED, - { original, updated, cascadedUpdates }, - { correlationId: req.id }, - ); - - // emit the event - // we cannot use `util.sendResourceToKafkaBus` as we have to pass a custom param `cascadedUpdates` - req.app.emit(EVENT.ROUTING_KEY.MILESTONE_UPDATED, { - req, - resource: _.assign({ resource: RESOURCES.MILESTONE }, updated), - originalResource: _.assign({ resource: RESOURCES.MILESTONE }, original), - cascadedUpdates, - }); - - // Write to response - res.json(updated); - return Promise.resolve(); - }) - .catch(next); - }, + (req, res, next) => + models + .sequelize + .transaction(t => updateMilestone( + req.authUser, + req.params.timelineId, + Object.assign({}, req.body, { id: req.params.milestoneId }), + t)) + .then((result) => { + util.sendResourceToKafkaBus( + req, + EVENT.ROUTING_KEY.MILESTONE_UPDATED, + RESOURCES.MILESTONE, + result); + res.json(result); + }) + .catch(next), ]; diff --git a/src/routes/milestones/update.spec.js b/src/routes/milestones/update.spec.js index bf8b1c7e..bdab45bc 100644 --- a/src/routes/milestones/update.spec.js +++ b/src/routes/milestones/update.spec.js @@ -11,7 +11,7 @@ import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; -import { EVENT, RESOURCES, MILESTONE_STATUS, BUS_API_EVENT, CONNECT_NOTIFICATION_EVENT } from '../../constants'; +import { RESOURCES, MILESTONE_STATUS, BUS_API_EVENT, CONNECT_NOTIFICATION_EVENT } from '../../constants'; const should = chai.should(); @@ -550,15 +550,12 @@ describe('UPDATE Milestone', () => { // validate statusHistory should.exist(resJson.statusHistory); resJson.statusHistory.should.be.an('array'); - resJson.statusHistory.length.should.be.eql(2); + resJson.statusHistory.length.should.be.eql(1); 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; - done(); }); }); @@ -575,26 +572,21 @@ describe('UPDATE Milestone', () => { .send(_.assign({}, body, { order: 4 })) // 1 to 4 .expect(200) .end(() => { - // Milestone 1: order 4 - // Milestone 2: order 2 - 1 = 1 - // Milestone 3: order 3 - 1 = 2 - // Milestone 4: order 4 - 1 = 3 models.Milestone.findByPk(1) .then((milestone) => { milestone.order.should.be.eql(4); }) .then(() => models.Milestone.findByPk(2)) .then((milestone) => { - milestone.order.should.be.eql(1); + milestone.order.should.be.eql(2); }) .then(() => models.Milestone.findByPk(3)) .then((milestone) => { - milestone.order.should.be.eql(2); + milestone.order.should.be.eql(3); }) .then(() => models.Milestone.findByPk(4)) .then((milestone) => { - milestone.order.should.be.eql(3); - + milestone.order.should.be.eql(4); done(); }); }); @@ -649,26 +641,21 @@ describe('UPDATE Milestone', () => { .send(_.assign({}, body, { order: 2 })) // 4 to 2 .expect(200) .end(() => { - // Milestone 1: order 1 - // Milestone 2: order 3 - // Milestone 3: order 4 - // Milestone 4: order 2 models.Milestone.findByPk(1) .then((milestone) => { milestone.order.should.be.eql(1); }) .then(() => models.Milestone.findByPk(2)) .then((milestone) => { - milestone.order.should.be.eql(3); + milestone.order.should.be.eql(2); }) .then(() => models.Milestone.findByPk(3)) .then((milestone) => { - milestone.order.should.be.eql(4); + milestone.order.should.be.eql(3); }) .then(() => models.Milestone.findByPk(4)) .then((milestone) => { milestone.order.should.be.eql(2); - done(); }); }); @@ -858,16 +845,13 @@ describe('UPDATE Milestone', () => { .send(_.assign({}, body, { order: 2 })) // 4 to 2 .expect(200) .end(() => { - // Milestone 6: order 1 => 1 - // Milestone 7: order 2 => 3 - // Milestone 8: order 4 => 2 models.Milestone.findByPk(6) .then((milestone) => { milestone.order.should.be.eql(1); }) .then(() => models.Milestone.findByPk(7)) .then((milestone) => { - milestone.order.should.be.eql(3); + milestone.order.should.be.eql(2); }) .then(() => models.Milestone.findByPk(8)) .then((milestone) => { @@ -880,198 +864,83 @@ describe('UPDATE Milestone', () => { }); }); - it('should return 200 for admin - marking milestone active later will cascade changes to coming ' + + it('should return 200 for admin - marking milestone active later will adjust actual start date and end date' // eslint-disable-next-line func-names - 'milestones', function (done) { - this.timeout(10000); - const today = moment.utc().hours(0).minutes(0).seconds(0) - .milliseconds(0); + , function (done) { + this.timeout(10000); + const today = moment.utc().hours(0).minutes(0).seconds(0) + .milliseconds(0); - request(server) - .patch('/v5/timelines/1/milestones/2') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send({ status: MILESTONE_STATUS.ACTIVE }) - .expect(200) - .end(() => { - // Milestone 2: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-14T00:00:00.000Z' - // actualStartDate: null to today - // endDate: null to today + 2 (2 = duration - 1) - // Milestone 3: startDate: '2018-05-14T00:00:00.000Z' to today + 3 - // endDate: null to today + 5 (5 = 3 + duration - 1) - // Milestone 4: startDate: '2018-05-14T00:00:00.000Z' to today + 6 - // endDate: null to today + 8 (2 = 6 + duration - 1) - models.Milestone.findByPk(2) - .then((milestone) => { - should.exist(milestone.actualStartDate); - moment.utc(milestone.actualStartDate).diff(today, 'days').should.be.eql(0); - // start date of the updated milestone should not change - milestone.startDate.should.be.eql(new Date('2018-05-14T00:00:00.000Z')); - today.add('days', milestone.duration - 1); - // end date of the updated milestone should change, as delayed start caused scheduled to be delayed - moment.utc(milestone.endDate).diff(today, 'days').should.be.eql(0); - milestone.status.should.be.eql(MILESTONE_STATUS.ACTIVE); - return models.Milestone.findByPk(3); - }) - .then((milestone) => { - today.add('days', 1); // should have start date next to previous one's end date - moment.utc(milestone.startDate).diff(today, 'days').should.be.eql(0); - should.not.exist(milestone.actualStartDate); - today.add('days', milestone.duration - 1); - moment.utc(milestone.endDate).diff(today, 'days').should.be.eql(0); - return models.Milestone.findByPk(4); - }) - .then((milestone) => { - today.add('days', 1); // should have start date next to previous one's end date - moment.utc(milestone.startDate).diff(today, 'days').should.be.eql(0); - should.not.exist(milestone.actualStartDate); - today.add('days', milestone.duration - 1); - moment.utc(milestone.endDate).diff(today, 'days').should.be.eql(0); - done(); - }) - .catch(done); - }); - }); - - it('should return 200 for admin - changing completionDate will cascade changes to coming ' + - // eslint-disable-next-line func-names - 'milestones', function (done) { - this.timeout(10000); - const today = moment.utc().hours(0).minutes(0).seconds(0) - .milliseconds(0); - - request(server) - .patch('/v5/timelines/1/milestones/2') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(_.assign({}, body, { - completionDate: '2018-05-18T00:00:00.000Z', order: undefined, duration: undefined, - })) - .expect(200) - .end(() => { - // Milestone 3: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-19T00:00:00.000Z' - // endDate: null to '2018-05-21T00:00:00.000Z' - // Milestone 4: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-22T00:00:00.000Z' - // endDate: null to '2018-05-24T00:00:00.000Z' - models.Milestone.findByPk(3) - .then((milestone) => { - milestone.startDate.should.be.eql(new Date('2018-05-19T00:00:00.000Z')); - should.exist(milestone.actualStartDate); - moment().utc(milestone.actualStartDate).diff(today, 'days').should.be.eql(0); - // milestone.actualStartDate.should.be.eql(today); - milestone.endDate.should.be.eql(new Date('2018-05-21T00:00:00.000Z')); - milestone.status.should.be.eql(MILESTONE_STATUS.ACTIVE); - return models.Milestone.findByPk(4); - }) - .then((milestone) => { - milestone.startDate.should.be.eql(new Date('2018-05-22T00:00:00.000Z')); - should.not.exist(milestone.actualStartDate); - milestone.endDate.should.be.eql(new Date('2018-05-24T00:00:00.000Z')); - done(); - }) - .catch(done); - }); - }); - - it('should return 200 for admin - changing completionDate will change the timeline\'s ' + - // eslint-disable-next-line func-names - 'endDate', function (done) { - this.timeout(10000); - - request(server) - .patch('/v5/timelines/1/milestones/2') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(_.assign({}, body, { - completionDate: '2018-05-18T00:00:00.000Z', order: undefined, duration: undefined, - })) - .expect(200) - .end(() => { - // Milestone 3: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-19T00:00:00.000Z' - // endDate: null to '2018-05-21T00:00:00.000Z' - // Milestone 4: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-22T00:00:00.000Z' - // BELOW will be the new timeline's endDate - // endDate: null to '2018-05-24T00:00:00.000Z' - models.Timeline.findByPk(1) - .then((timeline) => { - // timeline start shouldn't change - timeline.startDate.should.be.eql(new Date('2018-05-02T00:00:00.000Z')); - - // timeline end should change - timeline.endDate.should.be.eql(new Date('2018-05-24T00:00:00.000Z')); - - done(); - }) - .catch(done); - }); - }); + request(server) + .patch('/v5/timelines/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ status: MILESTONE_STATUS.ACTIVE }) + .expect(200) + .end(() => { + models.Milestone.findByPk(2) + .then((milestone) => { + should.exist(milestone.actualStartDate); + moment.utc(milestone.actualStartDate).diff(today, 'days').should.be.eql(0); + // start date of the updated milestone should not change + milestone.startDate.should.be.eql(new Date('2018-05-14T00:00:00.000Z')); + today.add('days', milestone.duration - 1); + // end date of the updated milestone should change, as delayed start caused scheduled to be delayed + moment.utc(milestone.endDate).diff(today, 'days').should.be.eql(0); + milestone.status.should.be.eql(MILESTONE_STATUS.ACTIVE); + done(); + }) + .catch(done); + }); + }); - it('should return 200 for admin - changing duration will cascade changes to coming ' + + it('should return 200 for admin - changing completionDate will set status to completed', // eslint-disable-next-line func-names - 'milestones', function (done) { - this.timeout(10000); - - request(server) - .patch('/v5/timelines/1/milestones/2') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(_.assign({}, body, { duration: 5, order: undefined, completionDate: undefined })) - .expect(200) - .end(() => { - // Milestone 3: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-19T00:00:00.000Z' - // endDate: null to '2018-05-21T00:00:00.000Z' - // Milestone 4: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-22T00:00:00.000Z' - // endDate: null to '2018-05-24T00:00:00.000Z' - models.Milestone.findByPk(3) - .then((milestone) => { - milestone.startDate.should.be.eql(new Date('2018-05-19T00:00:00.000Z')); - milestone.endDate.should.be.eql(new Date('2018-05-21T00:00:00.000Z')); - return models.Milestone.findByPk(4); - }) - .then((milestone) => { - milestone.startDate.should.be.eql(new Date('2018-05-22T00:00:00.000Z')); - milestone.endDate.should.be.eql(new Date('2018-05-24T00:00:00.000Z')); - done(); - }) - .catch(done); - }); - }); + function (done) { + this.timeout(10000); + const data = Object.assign({}, body, { completionDate: '2018-05-18T00:00:00.000Z', + order: undefined, + duration: undefined }); + delete data.status; + request(server) + .patch('/v5/timelines/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(data) + .expect(200) + .end(() => { + models.Milestone.findByPk(2) + .then((milestone) => { + milestone.status.should.be.eql(MILESTONE_STATUS.COMPLETED); + done(); + }) + .catch(done); + }); + }); - it('should return 200 for admin - changing duration will change the timeline\'s ' + + it('should return 200 for admin - changing duration will adjust the milestone endDate', // eslint-disable-next-line func-names - 'endDate', function (done) { - this.timeout(10000); - - request(server) - .patch('/v5/timelines/1/milestones/2') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(_.assign({}, body, { duration: 5, order: undefined, completionDate: undefined })) - .expect(200) - .end(() => { - // Milestone 3: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-19T00:00:00.000Z' - // endDate: null to '2018-05-21T00:00:00.000Z' - // Milestone 4: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-22T00:00:00.000Z' - // BELOW will be the new timeline's endDate - // endDate: null to '2018-05-24T00:00:00.000Z' - models.Timeline.findByPk(1) - .then((timeline) => { - // timeline start shouldn't change - timeline.startDate.should.be.eql(new Date('2018-05-02T00:00:00.000Z')); - - // timeline end should change - timeline.endDate.should.be.eql(new Date('2018-05-24T00:00:00.000Z')); - - done(); - }) - .catch(done); - }); - }); + function (done) { + this.timeout(10000); + request(server) + .patch('/v5/timelines/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(_.assign({}, body, { duration: 5, order: undefined, completionDate: undefined })) + .expect(200) + .end(() => { + models.Milestone.findByPk(2) + .then((milestone) => { + milestone.startDate.should.be.eql(new Date('2018-05-14T00:00:00.000Z')); + milestone.endDate.should.be.eql(new Date('2018-05-18T00:00:00.000Z')); + done(); + }) + .catch(done); + }); + }); it('should return 200 for connect admin', (done) => { request(server) @@ -1315,7 +1184,7 @@ describe('UPDATE Milestone', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.callCount.should.be.eql(3); + createEventSpy.callCount.should.be.eql(4); createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, sinon.match({ resource: RESOURCES.MILESTONE, @@ -1341,7 +1210,7 @@ describe('UPDATE Milestone', () => { }); }); - xit('should send message BUS_API_EVENT.MILESTONE_UPDATED when milestone duration updated', (done) => { + it('should send message BUS_API_EVENT.MILESTONE_UPDATED when milestone duration updated', (done) => { request(server) .patch('/v5/timelines/1/milestones/1') .set({ @@ -1356,10 +1225,7 @@ describe('UPDATE Milestone', () => { done(err); } else { testUtil.wait(() => { - // 5 milestones in total, so it would trigger 5 events - // 4 MILESTONE_UPDATED events are for 4 non deleted milestones - // 1 TIMELINE_ADJUSTED event, because timeline's end date updated - createEventSpy.calledOnce.should.be.true; + createEventSpy.calledOnce.should.be.false; createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, sinon.match({ resource: RESOURCES.MILESTONE })).should.be.true; createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, @@ -1370,7 +1236,7 @@ describe('UPDATE Milestone', () => { }); }); - xit('should send message BUS_API_EVENT.MILESTONE_UPDATED when milestone status updated', (done) => { + it('should send message BUS_API_EVENT.MILESTONE_UPDATED when milestone status updated', (done) => { request(server) .patch('/v5/timelines/1/milestones/1') .set({ @@ -1385,7 +1251,7 @@ describe('UPDATE Milestone', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; + createEventSpy.calledOnce.should.be.false; createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, sinon.match({ resource: RESOURCES.MILESTONE })).should.be.true; createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, @@ -1411,22 +1277,12 @@ describe('UPDATE Milestone', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.callCount.should.be.eql(2); + createEventSpy.callCount.should.be.eql(3); createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, sinon.match({ resource: RESOURCES.MILESTONE, order: 2, })).should.be.true; - - // Check Notification Service events - createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.MILESTONE_UPDATED, sinon.match({ - projectId: 1, - projectName: 'test1', - projectUrl: 'https://local.topcoder-dev.com/projects/1', - userId: 40051332, - initiatorUserId: 40051332, - })).should.be.true; - done(); }); } @@ -1448,22 +1304,13 @@ describe('UPDATE Milestone', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.callCount.should.be.eql(2); + createEventSpy.callCount.should.be.eql(3); createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, sinon.match({ resource: RESOURCES.MILESTONE, plannedText: 'new text', })).should.be.true; - // Check Notification Service events - createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.MILESTONE_UPDATED, sinon.match({ - projectId: 1, - projectName: 'test1', - projectUrl: 'https://local.topcoder-dev.com/projects/1', - userId: 40051332, - initiatorUserId: 40051332, - })).should.be.true; - done(); }); }