From 1e65c939eb4428cd7fa89d81bdecae39715a99e9 Mon Sep 17 00:00:00 2001 From: Paulo Vitor Magacho Date: Sat, 26 May 2018 08:13:49 -0300 Subject: [PATCH] merge from project and product templates --- .circleci/config.yml | 2 +- README.md | 2 +- postman.json | 242 ++++++++- postman_environment.json | 23 + src/models/productTemplate.js | 32 ++ src/models/projectTemplate.js | 30 ++ src/permissions/connectManagerOrAdmin.ops.js | 18 + src/permissions/index.js | 11 + src/routes/index.js | 35 +- src/routes/productTemplates/create.js | 51 ++ src/routes/productTemplates/create.spec.js | 157 ++++++ src/routes/productTemplates/delete.js | 55 ++ src/routes/productTemplates/delete.spec.js | 129 +++++ src/routes/productTemplates/get.js | 41 ++ src/routes/productTemplates/get.spec.js | 153 ++++++ src/routes/productTemplates/list.js | 23 + src/routes/productTemplates/list.spec.js | 148 ++++++ src/routes/productTemplates/update.js | 72 +++ src/routes/productTemplates/update.spec.js | 244 +++++++++ src/routes/projectTemplates/create.js | 49 ++ src/routes/projectTemplates/create.spec.js | 153 ++++++ src/routes/projectTemplates/delete.js | 55 ++ src/routes/projectTemplates/delete.spec.js | 127 +++++ src/routes/projectTemplates/get.js | 41 ++ src/routes/projectTemplates/get.spec.js | 148 ++++++ src/routes/projectTemplates/list.js | 23 + src/routes/projectTemplates/list.spec.js | 141 ++++++ src/routes/projectTemplates/update.js | 70 +++ src/routes/projectTemplates/update.spec.js | 237 +++++++++ src/tests/seed.js | 296 +++++++---- src/util.js | 80 +-- swagger.yaml | 500 +++++++++++++++++++ 32 files changed, 3237 insertions(+), 151 deletions(-) create mode 100644 postman_environment.json create mode 100644 src/models/productTemplate.js create mode 100644 src/models/projectTemplate.js create mode 100644 src/permissions/connectManagerOrAdmin.ops.js create mode 100644 src/routes/productTemplates/create.js create mode 100644 src/routes/productTemplates/create.spec.js create mode 100644 src/routes/productTemplates/delete.js create mode 100644 src/routes/productTemplates/delete.spec.js create mode 100644 src/routes/productTemplates/get.js create mode 100644 src/routes/productTemplates/get.spec.js create mode 100644 src/routes/productTemplates/list.js create mode 100644 src/routes/productTemplates/list.spec.js create mode 100644 src/routes/productTemplates/update.js create mode 100644 src/routes/productTemplates/update.spec.js create mode 100644 src/routes/projectTemplates/create.js create mode 100644 src/routes/projectTemplates/create.spec.js create mode 100644 src/routes/projectTemplates/delete.js create mode 100644 src/routes/projectTemplates/delete.spec.js create mode 100644 src/routes/projectTemplates/get.js create mode 100644 src/routes/projectTemplates/get.spec.js create mode 100644 src/routes/projectTemplates/list.js create mode 100644 src/routes/projectTemplates/list.spec.js create mode 100644 src/routes/projectTemplates/update.js create mode 100644 src/routes/projectTemplates/update.spec.js diff --git a/.circleci/config.yml b/.circleci/config.yml index cfc1f527..4fea1158 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -76,7 +76,7 @@ workflows: - test filters: branches: - only: [dev, 'feature/db-lock-issue'] + only: dev - deployProd: requires: - test diff --git a/README.md b/README.md index 2a18f10c..e2e5a707 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Run image: `docker run -p 3000:3000 -i -t -e DB_HOST=172.17.0.1 tc_projects_services` You may replace 172.17.0.1 with your docker0 IP. -You can paste **swagger.yaml** to [swagger editor](http://editor.swagger.io/) or import **postman.json** to verify endpoints. +You can paste **swagger.yaml** to [swagger editor](http://editor.swagger.io/) or import **postman.json** and **postman_environment.json** to verify endpoints. #### Deploying without docker If you don't want to use docker to deploy to localhost. You can simply run `npm run start` from root of project. This should start the server on default port `3000`. diff --git a/postman.json b/postman.json index 807c4f87..314c5d4b 100644 --- a/postman.json +++ b/postman.json @@ -1,13 +1,13 @@ { "info": { + "_postman_id": "469f0f40-b34a-40f9-b89d-bd7fde996676", "name": "tc-project-service ", - "_postman_id": "8f323d9c-63bd-5f2c-87f1-1e99083786f3", - "description": "", "schema": "https://schema.getpostman.com/json/collection/v2.0.0/collection.json" }, "item": [ { "name": "Project Attachments", + "description": null, "item": [ { "name": "Upload attachment", @@ -82,6 +82,7 @@ }, { "name": "Project Members", + "description": null, "item": [ { "name": "Create project member with no payload", @@ -900,6 +901,7 @@ }, { "name": "bookmarks", + "description": null, "item": [ { "name": " Create project without bookmarks", @@ -1077,6 +1079,7 @@ }, { "name": "issue1", + "description": null, "item": [ { "name": "get projects with copilot token", @@ -1100,6 +1103,7 @@ }, { "name": "issue10", + "description": null, "item": [ { "name": "wrong role", @@ -1149,6 +1153,7 @@ }, { "name": "issue5", + "description": null, "item": [ { "name": "launch a project by topcoder managers ", @@ -1220,6 +1225,7 @@ }, { "name": "issue8", + "description": null, "item": [ { "name": "mock direct projects", @@ -1376,6 +1382,238 @@ "response": [] } ] + }, + { + "name": "Project Templates", + "description": "", + "item": [ + { + "name": "Create project template", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": "{{api-url}}/v4/projectTemplates" + }, + "response": [] + }, + { + "name": "List project templates", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": "{{api-url}}/v4/projectTemplates" + }, + "response": [] + }, + { + "name": "Get project template", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": "{{api-url}}/v4/projectTemplates/1" + }, + "response": [] + }, + { + "name": "Update project template", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + }, + "url": "{{api-url}}/v4/projectTemplates/1" + }, + "response": [] + }, + { + "name": "Delete project template", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + }, + "url": "{{api-url}}/v4/projectTemplates/1" + }, + "response": [] + } + ] + }, + { + "name": "Product Templates", + "description": "", + "item": [ + { + "name": "Create product template", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"productKey\":\"new productKey\",\r\n \"icon\":\"http://example.com/icon-new.ico\",\r\n \"brief\": \"new brief\",\r\n \"details\": \"new details\",\r\n \"aliases\":{\r\n \"alias1\":\"alias 1\"\r\n },\r\n \"template\":{\r\n \"template1\":\"template 1\"\r\n }\r\n }\r\n}" + }, + "url": "{{api-url}}/v4/productTemplates" + }, + "response": [] + }, + { + "name": "List product templates", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": "{{api-url}}/v4/productTemplates" + }, + "response": [] + }, + { + "name": "Get product template", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": "{{api-url}}/v4/productTemplates/1" + }, + "response": [] + }, + { + "name": "Update product template", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"productKey\":\"new productKey\",\r\n \"icon\":\"http://example.com/icon-new.ico\",\r\n \"brief\": \"new brief\",\r\n \"details\": \"new details\",\r\n \"aliases\":{\r\n \"alias1\":\"scope 1\",\r\n \"alias2\": [\"a\"]\r\n },\r\n \"template\":{\r\n \"template1\":\"template 1\",\r\n \"template2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + }, + "url": "{{api-url}}/v4/productTemplates/1" + }, + "response": [] + }, + { + "name": "Delete product template", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + }, + "url": "{{api-url}}/v4/productTemplates/1" + }, + "response": [] + } + ] } ] } \ No newline at end of file diff --git a/postman_environment.json b/postman_environment.json new file mode 100644 index 00000000..12fab912 --- /dev/null +++ b/postman_environment.json @@ -0,0 +1,23 @@ +{ + "id": "e6b30b4b-1388-4622-8314-bc49ba1d752b", + "name": "tc-project-service", + "values": [ + { + "key": "api-url", + "value": "http://localhost:3000", + "description": "", + "type": "text", + "enabled": true + }, + { + "key": "jwt-token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "description": "", + "type": "text", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2018-05-18T18:54:18.167Z", + "_postman_exported_using": "Postman/6.0.10" +} \ No newline at end of file diff --git a/src/models/productTemplate.js b/src/models/productTemplate.js new file mode 100644 index 00000000..72d7bc30 --- /dev/null +++ b/src/models/productTemplate.js @@ -0,0 +1,32 @@ +/* eslint-disable valid-jsdoc */ + +/** + * The Product Template model + */ +module.exports = (sequelize, DataTypes) => { + const ProductTemplate = sequelize.define('ProductTemplate', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING(255), allowNull: false }, + productKey: { type: DataTypes.STRING(45), allowNull: false }, + icon: { type: DataTypes.STRING(255), allowNull: false }, + brief: { type: DataTypes.STRING(45), allowNull: false }, + details: { type: DataTypes.STRING(255), allowNull: false }, + aliases: { type: DataTypes.JSON, allowNull: false }, + template: { type: DataTypes.JSON, allowNull: false }, + deletedAt: DataTypes.DATE, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.BIGINT, + createdBy: { type: DataTypes.BIGINT, allowNull: false }, + updatedBy: { type: DataTypes.BIGINT, allowNull: false }, + }, { + tableName: 'product_templates', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + }); + + return ProductTemplate; +}; diff --git a/src/models/projectTemplate.js b/src/models/projectTemplate.js new file mode 100644 index 00000000..206fa9e0 --- /dev/null +++ b/src/models/projectTemplate.js @@ -0,0 +1,30 @@ +/* eslint-disable valid-jsdoc */ + +/** + * The Project Template model + */ +module.exports = (sequelize, DataTypes) => { + const ProjectTemplate = sequelize.define('ProjectTemplate', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING(255), allowNull: false }, + key: { type: DataTypes.STRING(45), allowNull: false }, + category: { type: DataTypes.STRING(45), allowNull: false }, + scope: { type: DataTypes.JSON, allowNull: false }, + phases: { type: DataTypes.JSON, allowNull: false }, + deletedAt: DataTypes.DATE, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.BIGINT, + createdBy: { type: DataTypes.BIGINT, allowNull: false }, + updatedBy: { type: DataTypes.BIGINT, allowNull: false }, + }, { + tableName: 'project_templates', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + }); + + return ProjectTemplate; +}; diff --git a/src/permissions/connectManagerOrAdmin.ops.js b/src/permissions/connectManagerOrAdmin.ops.js new file mode 100644 index 00000000..0a5fb15a --- /dev/null +++ b/src/permissions/connectManagerOrAdmin.ops.js @@ -0,0 +1,18 @@ +import util from '../util'; +import { MANAGER_ROLES } from '../constants'; + + +/** + * Only Connect Manager, Connect Admin, and administrator are allowed to perform the operations + * @param {Object} req the express request instance + * @return {Promise} returns a promise + */ +module.exports = req => new Promise((resolve, reject) => { + const hasAccess = util.hasRoles(req, MANAGER_ROLES); + + if (!hasAccess) { + return reject(new Error('You do not have permissions to perform this action')); + } + + return resolve(true); +}); diff --git a/src/permissions/index.js b/src/permissions/index.js index e0797b3c..54365072 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -6,6 +6,7 @@ const projectEdit = require('./project.edit'); const projectDelete = require('./project.delete'); const projectMemberDelete = require('./projectMember.delete'); const projectAdmin = require('./admin.ops'); +const connectManagerOrAdmin = require('./connectManagerOrAdmin.ops'); module.exports = () => { Authorizer.setDeniedStatusCode(403); @@ -23,4 +24,14 @@ module.exports = () => { Authorizer.setPolicy('project.downloadAttachment', projectView); Authorizer.setPolicy('project.updateMember', projectEdit); Authorizer.setPolicy('project.admin', projectAdmin); + + Authorizer.setPolicy('projectTemplate.create', connectManagerOrAdmin); + Authorizer.setPolicy('projectTemplate.edit', connectManagerOrAdmin); + Authorizer.setPolicy('projectTemplate.delete', connectManagerOrAdmin); + Authorizer.setPolicy('projectTemplate.view', true); + + Authorizer.setPolicy('productTemplate.create', connectManagerOrAdmin); + Authorizer.setPolicy('productTemplate.edit', connectManagerOrAdmin); + Authorizer.setPolicy('productTemplate.delete', connectManagerOrAdmin); + Authorizer.setPolicy('productTemplate.view', true); }; diff --git a/src/routes/index.js b/src/routes/index.js index 47a51502..35abd8ba 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -26,7 +26,9 @@ router.get(`/${apiVersion}/projects/health`, (req, res) => { // All project service endpoints need authentication const jwtAuth = require('tc-core-library-js').middleware.jwtAuthenticator; -router.all(RegExp(`\\/${apiVersion}\\/projects(?!\\/health).*`), jwtAuth()); +router.all( + RegExp(`\\/${apiVersion}\\/(projects|projectTemplates|productTemplates)(?!\\/health).*`), + jwtAuth()); // Register all the routes router.route('/v4/projects') @@ -51,19 +53,34 @@ router.route('/v4/projects/:projectId(\\d+)') .delete(require('./projects/delete')); router.route('/v4/projects/:projectId(\\d+)/members') - .post(require('./projectMembers/create')); + .post(require('./projectMembers/create')); router.route('/v4/projects/:projectId(\\d+)/members/:id(\\d+)') - .delete(require('./projectMembers/delete')) - .patch(require('./projectMembers/update')); + .delete(require('./projectMembers/delete')) + .patch(require('./projectMembers/update')); router.route('/v4/projects/:projectId(\\d+)/attachments') - .post(require('./attachments/create')); + .post(require('./attachments/create')); router.route('/v4/projects/:projectId(\\d+)/attachments/:id(\\d+)') - .get(require('./attachments/download')) - .patch(require('./attachments/update')) - .delete(require('./attachments/delete')); - + .get(require('./attachments/download')) + .patch(require('./attachments/update')) + .delete(require('./attachments/delete')); + +router.route('/v4/projectTemplates') + .post(require('./projectTemplates/create')) + .get(require('./projectTemplates/list')); +router.route('/v4/projectTemplates/:templateId(\\d+)') + .get(require('./projectTemplates/get')) + .patch(require('./projectTemplates/update')) + .delete(require('./projectTemplates/delete')); + +router.route('/v4/productTemplates') + .post(require('./productTemplates/create')) + .get(require('./productTemplates/list')); +router.route('/v4/productTemplates/:templateId(\\d+)') + .get(require('./productTemplates/get')) + .patch(require('./productTemplates/update')) + .delete(require('./productTemplates/delete')); // register error handler router.use((err, req, res, next) => { // eslint-disable-line no-unused-vars diff --git a/src/routes/productTemplates/create.js b/src/routes/productTemplates/create.js new file mode 100644 index 00000000..b00363e3 --- /dev/null +++ b/src/routes/productTemplates/create.js @@ -0,0 +1,51 @@ +/** + * API to add a product template + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + productKey: Joi.string().max(45).required(), + icon: Joi.string().max(255).required(), + brief: Joi.string().max(45).required(), + details: Joi.string().max(255).required(), + aliases: Joi.object().required(), + template: Joi.object().required(), + 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(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productTemplate.create'), + (req, res, next) => { + const entity = _.assign(req.body.param, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + return models.ProductTemplate.create(entity) + .then((createdEntity) => { + // Omit deletedAt, deletedBy + res.status(201).json(util.wrapResponse( + req.id, _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'), 1, 201)); + }) + .catch(next); + }, +]; diff --git a/src/routes/productTemplates/create.spec.js b/src/routes/productTemplates/create.spec.js new file mode 100644 index 00000000..8476a5ef --- /dev/null +++ b/src/routes/productTemplates/create.spec.js @@ -0,0 +1,157 @@ +/** + * Tests for create.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('CREATE product template', () => { + describe('POST /productTemplates', () => { + const body = { + param: { + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post('/v4/productTemplates') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 422 if validations dont pass', (done) => { + const invalidBody = { + param: { + aliases: 'a', + template: 1, + }, + }; + + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson.id); + resJson.name.should.be.eql(body.param.name); + resJson.productKey.should.be.eql(body.param.productKey); + resJson.icon.should.be.eql(body.param.icon); + resJson.brief.should.be.eql(body.param.brief); + resJson.details.should.be.eql(body.param.details); + resJson.aliases.should.be.eql(body.param.aliases); + resJson.template.should.be.eql(body.param.template); + + resJson.createdBy.should.be.eql(40051333); // admin + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 201 for connect manager', (done) => { + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051334); // manager + resJson.updatedBy.should.be.eql(40051334); // manager + done(); + }); + }); + + it('should return 201 for connect admin', (done) => { + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051336); // connect admin + resJson.updatedBy.should.be.eql(40051336); // connect admin + done(); + }); + }); + }); +}); diff --git a/src/routes/productTemplates/delete.js b/src/routes/productTemplates/delete.js new file mode 100644 index 00000000..81c65b6b --- /dev/null +++ b/src/routes/productTemplates/delete.js @@ -0,0 +1,55 @@ +/** + * API to delete a product template + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + templateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productTemplate.delete'), + (req, res, next) => { + const where = { + deletedAt: { $eq: null }, + id: req.params.templateId, + }; + + return models.sequelize.transaction(tx => + // Update the deletedBy + models.ProductTemplate.update({ deletedBy: req.authUser.userId }, { + where, + returning: true, + raw: true, + transaction: tx, + }) + .then((updatedResults) => { + // Not found + if (updatedResults[0] === 0) { + const apiErr = new Error(`Product template not found for template id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Soft delete + return models.ProductTemplate.destroy({ + where, + transaction: tx, + raw: true, + }); + }) + .then(() => { + res.status(204).end(); + }) + .catch(next), + ); + }, +]; diff --git a/src/routes/productTemplates/delete.spec.js b/src/routes/productTemplates/delete.spec.js new file mode 100644 index 00000000..058fea4c --- /dev/null +++ b/src/routes/productTemplates/delete.spec.js @@ -0,0 +1,129 @@ +/** + * Tests for delete.js + */ +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + + +describe('DELETE product template', () => { + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.create({ + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + })).then((template) => { + templateId = template.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('DELETE /productTemplates/{templateId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .delete('/v4/productTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProductTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 204, for admin, if template was successfully removed', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for connect admin, if template was successfully removed', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for connect manager, if template was successfully removed', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(204) + .end(done); + }); + }); +}); diff --git a/src/routes/productTemplates/get.js b/src/routes/productTemplates/get.js new file mode 100644 index 00000000..e660f979 --- /dev/null +++ b/src/routes/productTemplates/get.js @@ -0,0 +1,41 @@ +/** + * API to get a product template + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + templateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productTemplate.view'), + (req, res, next) => models.ProductTemplate.findOne({ + where: { + deletedAt: { $eq: null }, + id: req.params.templateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((productTemplate) => { + // Not found + if (!productTemplate) { + const apiErr = new Error(`Product template not found for product id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + res.json(util.wrapResponse(req.id, productTemplate)); + return Promise.resolve(); + }) + .catch(next), +]; diff --git a/src/routes/productTemplates/get.spec.js b/src/routes/productTemplates/get.spec.js new file mode 100644 index 00000000..0fbdf37e --- /dev/null +++ b/src/routes/productTemplates/get.spec.js @@ -0,0 +1,153 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('GET product template', () => { + const template = { + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.create(template)) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('GET /productTemplates/{templateId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .expect(403, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .get('/v4/productTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProductTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(templateId); + resJson.name.should.be.eql(template.name); + resJson.productKey.should.be.eql(template.productKey); + resJson.icon.should.be.eql(template.icon); + resJson.brief.should.be.eql(template.brief); + resJson.details.should.be.eql(template.details); + resJson.aliases.should.be.eql(template.aliases); + resJson.template.should.be.eql(template.template); + + resJson.createdBy.should.be.eql(template.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(template.updatedBy); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/productTemplates/list.js b/src/routes/productTemplates/list.js new file mode 100644 index 00000000..11a9e276 --- /dev/null +++ b/src/routes/productTemplates/list.js @@ -0,0 +1,23 @@ +/** + * API to list all product templates + */ +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + permissions('productTemplate.view'), + (req, res, next) => models.ProductTemplate.findAll({ + where: { + deletedAt: { $eq: null }, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((productTemplates) => { + res.json(util.wrapResponse(req.id, productTemplates)); + }) + .catch(next), +]; diff --git a/src/routes/productTemplates/list.spec.js b/src/routes/productTemplates/list.spec.js new file mode 100644 index 00000000..e487d777 --- /dev/null +++ b/src/routes/productTemplates/list.spec.js @@ -0,0 +1,148 @@ +/** + * Tests for list.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('LIST product templates', () => { + const templates = [ + { + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'template 2', + productKey: 'productKey 2', + icon: 'http://example.com/icon2.ico', + brief: 'brief 2', + details: 'details 2', + aliases: {}, + template: {}, + createdBy: 3, + updatedBy: 4, + }, + ]; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.create(templates[0])) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return models.ProductTemplate.create(templates[1]); + }).then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('GET /productTemplates', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/productTemplates') + .expect(403, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const template = templates[0]; + + const resJson = res.body.result.content; + resJson.should.have.length(2); + resJson[0].id.should.be.eql(templateId); + resJson[0].name.should.be.eql(template.name); + resJson[0].productKey.should.be.eql(template.productKey); + resJson[0].icon.should.be.eql(template.icon); + resJson[0].brief.should.be.eql(template.brief); + resJson[0].details.should.be.eql(template.details); + resJson[0].aliases.should.be.eql(template.aliases); + resJson[0].template.should.be.eql(template.template); + + resJson[0].createdBy.should.be.eql(template.createdBy); + should.exist(resJson[0].createdAt); + resJson[0].updatedBy.should.be.eql(template.updatedBy); + should.exist(resJson[0].updatedAt); + should.not.exist(resJson[0].deletedBy); + should.not.exist(resJson[0].deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/productTemplates/update.js b/src/routes/productTemplates/update.js new file mode 100644 index 00000000..c5ebf633 --- /dev/null +++ b/src/routes/productTemplates/update.js @@ -0,0 +1,72 @@ +/** + * API to update a product template + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + templateId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + productKey: Joi.string().max(45).required(), + icon: Joi.string().max(255).required(), + brief: Joi.string().max(45).required(), + details: Joi.string().max(255).required(), + aliases: Joi.object().required(), + template: Joi.object().required(), + 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(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productTemplate.edit'), + (req, res, next) => { + const entityToUpdate = _.assign(req.body.param, { + updatedBy: req.authUser.userId, + }); + + return models.ProductTemplate.findOne({ + where: { + deletedAt: { $eq: null }, + id: req.params.templateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((productTemplate) => { + // Not found + if (!productTemplate) { + const apiErr = new Error(`Product template not found for template id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Merge JSON fields + entityToUpdate.aliases = util.mergeJsonObjects(productTemplate.aliases, entityToUpdate.aliases); + entityToUpdate.template = util.mergeJsonObjects(productTemplate.template, entityToUpdate.template); + + return productTemplate.update(entityToUpdate); + }) + .then((productTemplate) => { + res.json(util.wrapResponse(req.id, productTemplate)); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/productTemplates/update.spec.js b/src/routes/productTemplates/update.spec.js new file mode 100644 index 00000000..0d5c5e0e --- /dev/null +++ b/src/routes/productTemplates/update.spec.js @@ -0,0 +1,244 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('UPDATE product template', () => { + const template = { + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.create(template)) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('PATCH /productTemplates/{templateId}', () => { + const body = { + param: { + name: 'template 1 - update', + productKey: 'productKey 1 - update', + icon: 'http://example.com/icon1-update.ico', + brief: 'brief 1 - update', + details: 'details 1 - update', + aliases: { + alias1: { + subAlias1A: 11, + subAlias1C: 'new', + }, + alias2: [4], + alias3: 'new', + }, + template: { + template1: { + name: 'template 1 - update', + details: { + anyDetails: 'any details 1 - update', + newDetails: 'new', + }, + others: ['others new'], + }, + template3: { + name: 'template 3', + details: { + anyDetails: 'any details 3', + }, + others: ['others 31', 'others 32'], + }, + }, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 422 for invalid request', (done) => { + const invalidBody = { + param: { + aliases: 'a', + template: 1, + }, + }; + + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect(422, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .patch('/v4/productTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProductTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(templateId); + resJson.name.should.be.eql(body.param.name); + resJson.productKey.should.be.eql(body.param.productKey); + resJson.icon.should.be.eql(body.param.icon); + resJson.brief.should.be.eql(body.param.brief); + resJson.details.should.be.eql(body.param.details); + + resJson.aliases.should.be.eql({ + alias1: { + subAlias1A: 11, + subAlias1B: 2, + subAlias1C: 'new', + }, + alias2: [4], + alias3: 'new', + }); + resJson.template.should.be.eql({ + template1: { + name: 'template 1 - update', + details: { + anyDetails: 'any details 1 - update', + newDetails: 'new', + }, + others: ['others new'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + template3: { + name: 'template 3', + details: { + anyDetails: 'any details 3', + }, + others: ['others 31', 'others 32'], + }, + }); + resJson.createdBy.should.be.eql(template.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(200) + .end(done); + }); + }); +}); diff --git a/src/routes/projectTemplates/create.js b/src/routes/projectTemplates/create.js new file mode 100644 index 00000000..4c19fc0f --- /dev/null +++ b/src/routes/projectTemplates/create.js @@ -0,0 +1,49 @@ +/** + * API to add a project template + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + key: Joi.string().max(45).required(), + category: Joi.string().max(45).required(), + scope: Joi.object().required(), + phases: Joi.object().required(), + 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(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectTemplate.create'), + (req, res, next) => { + const entity = _.assign(req.body.param, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + return models.ProjectTemplate.create(entity) + .then((createdEntity) => { + // Omit deletedAt, deletedBy + res.status(201).json(util.wrapResponse( + req.id, _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'), 1, 201)); + }) + .catch(next); + }, +]; diff --git a/src/routes/projectTemplates/create.spec.js b/src/routes/projectTemplates/create.spec.js new file mode 100644 index 00000000..afb46113 --- /dev/null +++ b/src/routes/projectTemplates/create.spec.js @@ -0,0 +1,153 @@ +/** + * Tests for create.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('CREATE project template', () => { + describe('POST /projectTemplates', () => { + const body = { + param: { + name: 'template 1', + key: 'key 1', + category: 'category 1', + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post('/v4/projectTemplates') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 422 if validations dont pass', (done) => { + const invalidBody = { + param: { + scope: 'a', + phases: 1, + }, + }; + + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson.id); + resJson.name.should.be.eql(body.param.name); + resJson.key.should.be.eql(body.param.key); + resJson.category.should.be.eql(body.param.category); + resJson.scope.should.be.eql(body.param.scope); + resJson.phases.should.be.eql(body.param.phases); + + resJson.createdBy.should.be.eql(40051333); // admin + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 201 for connect manager', (done) => { + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051334); // manager + resJson.updatedBy.should.be.eql(40051334); // manager + done(); + }); + }); + + it('should return 201 for connect admin', (done) => { + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051336); // connect admin + resJson.updatedBy.should.be.eql(40051336); // connect admin + done(); + }); + }); + }); +}); diff --git a/src/routes/projectTemplates/delete.js b/src/routes/projectTemplates/delete.js new file mode 100644 index 00000000..4db9a855 --- /dev/null +++ b/src/routes/projectTemplates/delete.js @@ -0,0 +1,55 @@ +/** + * API to delete a project template + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + templateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectTemplate.delete'), + (req, res, next) => { + const where = { + deletedAt: { $eq: null }, + id: req.params.templateId, + }; + + return models.sequelize.transaction(tx => + // Update the deletedBy + models.ProjectTemplate.update({ deletedBy: req.authUser.userId }, { + where, + returning: true, + raw: true, + transaction: tx, + }) + .then((updatedResults) => { + // Not found + if (updatedResults[0] === 0) { + const apiErr = new Error(`Project template not found for template id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Soft delete + return models.ProjectTemplate.destroy({ + where, + transaction: tx, + raw: true, + }); + }) + .then(() => { + res.status(204).end(); + }) + .catch(next), + ); + }, +]; diff --git a/src/routes/projectTemplates/delete.spec.js b/src/routes/projectTemplates/delete.spec.js new file mode 100644 index 00000000..27973d7e --- /dev/null +++ b/src/routes/projectTemplates/delete.spec.js @@ -0,0 +1,127 @@ +/** + * Tests for delete.js + */ +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + + +describe('DELETE project template', () => { + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectTemplate.create({ + name: 'template 1', + key: 'key 1', + category: 'category 1', + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + })).then((template) => { + templateId = template.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('DELETE /projectTemplates/{templateId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .delete('/v4/projectTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProjectTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 204, for admin, if template was successfully removed', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for connect admin, if template was successfully removed', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for connect manager, if template was successfully removed', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(204) + .end(done); + }); + }); +}); diff --git a/src/routes/projectTemplates/get.js b/src/routes/projectTemplates/get.js new file mode 100644 index 00000000..e81c0939 --- /dev/null +++ b/src/routes/projectTemplates/get.js @@ -0,0 +1,41 @@ +/** + * API to get a project template + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + templateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectTemplate.view'), + (req, res, next) => models.ProjectTemplate.findOne({ + where: { + deletedAt: { $eq: null }, + id: req.params.templateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((projectTemplate) => { + // Not found + if (!projectTemplate) { + const apiErr = new Error(`Project template not found for project id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + res.json(util.wrapResponse(req.id, projectTemplate)); + return Promise.resolve(); + }) + .catch(next), +]; diff --git a/src/routes/projectTemplates/get.spec.js b/src/routes/projectTemplates/get.spec.js new file mode 100644 index 00000000..4c9b2ccf --- /dev/null +++ b/src/routes/projectTemplates/get.spec.js @@ -0,0 +1,148 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('GET project template', () => { + const template = { + name: 'template 1', + key: 'key 1', + category: 'category 1', + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + }; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectTemplate.create(template)) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('GET /projectTemplates/{templateId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .expect(403, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .get('/v4/projectTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProjectTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(templateId); + resJson.name.should.be.eql(template.name); + resJson.key.should.be.eql(template.key); + resJson.category.should.be.eql(template.category); + resJson.scope.should.be.eql(template.scope); + resJson.phases.should.be.eql(template.phases); + resJson.createdBy.should.be.eql(template.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(template.updatedBy); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/projectTemplates/list.js b/src/routes/projectTemplates/list.js new file mode 100644 index 00000000..3e83f2e4 --- /dev/null +++ b/src/routes/projectTemplates/list.js @@ -0,0 +1,23 @@ +/** + * API to list all project templates + */ +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + permissions('projectTemplate.view'), + (req, res, next) => models.ProjectTemplate.findAll({ + where: { + deletedAt: { $eq: null }, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((projectTemplates) => { + res.json(util.wrapResponse(req.id, projectTemplates)); + }) + .catch(next), +]; diff --git a/src/routes/projectTemplates/list.spec.js b/src/routes/projectTemplates/list.spec.js new file mode 100644 index 00000000..b68fc28d --- /dev/null +++ b/src/routes/projectTemplates/list.spec.js @@ -0,0 +1,141 @@ +/** + * Tests for list.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('LIST project templates', () => { + const templates = [ + { + name: 'template 1', + key: 'key 1', + category: 'category 1', + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'template 2', + key: 'key 2', + category: 'category 2', + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }, + ]; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectTemplate.create(templates[0])) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return models.ProjectTemplate.create(templates[1]); + }).then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('GET /projectTemplates', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/projectTemplates') + .expect(403, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const template = templates[0]; + + const resJson = res.body.result.content; + resJson.should.have.length(2); + resJson[0].id.should.be.eql(templateId); + resJson[0].name.should.be.eql(template.name); + resJson[0].key.should.be.eql(template.key); + resJson[0].category.should.be.eql(template.category); + resJson[0].scope.should.be.eql(template.scope); + resJson[0].phases.should.be.eql(template.phases); + resJson[0].createdBy.should.be.eql(template.createdBy); + should.exist(resJson[0].createdAt); + resJson[0].updatedBy.should.be.eql(template.updatedBy); + should.exist(resJson[0].updatedAt); + should.not.exist(resJson[0].deletedBy); + should.not.exist(resJson[0].deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/projectTemplates/update.js b/src/routes/projectTemplates/update.js new file mode 100644 index 00000000..8b88c84a --- /dev/null +++ b/src/routes/projectTemplates/update.js @@ -0,0 +1,70 @@ +/** + * API to update a project template + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + templateId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + key: Joi.string().max(45).required(), + category: Joi.string().max(45).required(), + scope: Joi.object().required(), + phases: Joi.object().required(), + 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(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectTemplate.edit'), + (req, res, next) => { + const entityToUpdate = _.assign(req.body.param, { + updatedBy: req.authUser.userId, + }); + + return models.ProjectTemplate.findOne({ + where: { + deletedAt: { $eq: null }, + id: req.params.templateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((projectTemplate) => { + // Not found + if (!projectTemplate) { + const apiErr = new Error(`Project template not found for template id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Merge JSON fields + entityToUpdate.scope = util.mergeJsonObjects(projectTemplate.scope, entityToUpdate.scope); + entityToUpdate.phases = util.mergeJsonObjects(projectTemplate.phases, entityToUpdate.phases); + + return projectTemplate.update(entityToUpdate); + }) + .then((projectTemplate) => { + res.json(util.wrapResponse(req.id, projectTemplate)); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/projectTemplates/update.spec.js b/src/routes/projectTemplates/update.spec.js new file mode 100644 index 00000000..dd286a77 --- /dev/null +++ b/src/routes/projectTemplates/update.spec.js @@ -0,0 +1,237 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('UPDATE project template', () => { + const template = { + name: 'template 1', + key: 'key 1', + category: 'category 1', + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + }; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectTemplate.create(template)) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('PATCH /projectTemplates/{templateId}', () => { + const body = { + param: { + name: 'template 1 - update', + key: 'key 1 - update', + category: 'category 1 - update', + scope: { + scope1: { + subScope1A: 11, + subScope1C: 'new', + }, + scope2: [4], + scope3: 'new', + }, + phases: { + phase1: { + name: 'phase 1 - update', + details: { + anyDetails: 'any details 1 - update', + newDetails: 'new', + }, + others: ['others new'], + }, + phase3: { + name: 'phase 3', + details: { + anyDetails: 'any details 3', + }, + others: ['others 31', 'others 32'], + }, + }, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 422 for invalid request', (done) => { + const invalidBody = { + param: { + scope: 'a', + phases: 1, + }, + }; + + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect(422, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .patch('/v4/projectTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProjectTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(templateId); + resJson.name.should.be.eql(body.param.name); + resJson.key.should.be.eql(body.param.key); + resJson.category.should.be.eql(body.param.category); + resJson.scope.should.be.eql({ + scope1: { + subScope1A: 11, + subScope1B: 2, + subScope1C: 'new', + }, + scope2: [4], + scope3: 'new', + }); + resJson.phases.should.be.eql({ + phase1: { + name: 'phase 1 - update', + details: { + anyDetails: 'any details 1 - update', + newDetails: 'new', + }, + others: ['others new'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + phase3: { + name: 'phase 3', + details: { + anyDetails: 'any details 3', + }, + others: ['others 31', 'others 32'], + }, + }); + resJson.createdBy.should.be.eql(template.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(200) + .end(done); + }); + }); +}); diff --git a/src/tests/seed.js b/src/tests/seed.js index a1f53c84..bbd0aa08 100644 --- a/src/tests/seed.js +++ b/src/tests/seed.js @@ -1,108 +1,194 @@ import models from '../models'; models.sequelize.sync({ force: true }) - .then(() => - models.Project.bulkCreate([{ - type: 'generic', - directProjectId: 9999999, - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'active', - details: {}, - createdBy: 1, - updatedBy: 1, - }, { - type: 'visual_design', - directProjectId: 1, - billingAccountId: 2, - name: 'test2', - description: 'test project2', - status: 'draft', - details: {}, - createdBy: 1, - updatedBy: 1, - }, { - type: 'visual_design', - billingAccountId: 3, - name: 'test2', - description: 'completed project without copilot', - status: 'completed', - details: {}, - createdBy: 1, - updatedBy: 1, - }, { - type: 'generic', - billingAccountId: 4, - name: 'test2', - description: 'draft project without copilot', - status: 'draft', - details: {}, - createdBy: 1, - updatedBy: 1, - }, { - type: 'generic', - billingAccountId: 5, - name: 'test2', - description: 'active project without copilot', - status: 'active', - details: {}, - createdBy: 1, - updatedBy: 1, - }])) - .then(() => models.Project.findAll()) - .then((projects) => { - const project1 = projects[0]; - const project2 = projects[1]; - const operations = []; - operations.push(models.ProjectMember.bulkCreate([{ - userId: 40051331, - projectId: project1.id, - role: 'customer', - isPrimary: false, - createdBy: 1, - updatedBy: 1, - }, { - userId: 40051332, - projectId: project1.id, - role: 'copilot', - isPrimary: false, - createdBy: 1, - updatedBy: 1, - }, { - userId: 40051333, - projectId: project1.id, - role: 'manager', - isPrimary: true, - createdBy: 1, - updatedBy: 1, - }, { - userId: 40051332, - projectId: project2.id, - role: 'copilot', - isPrimary: false, - createdBy: 1, - updatedBy: 1, - }, { - userId: 40051331, - projectId: projects[2].id, - role: 'customer', - isPrimary: false, - createdBy: 1, - updatedBy: 1, - }])); - operations.push(models.ProjectAttachment.create({ - title: 'Spec', - projectId: project1.id, - description: 'specification', - filePath: 'projects/1/spec.pdf', - contentType: 'application/pdf', - createdBy: 1, - updatedBy: 1, - })); - return Promise.all(operations); - }) - .then(() => { - process.exit(0); - }) - .catch(() => process.exit(1)); + .then(() => + models.Project.bulkCreate([{ + type: 'generic', + directProjectId: 9999999, + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'active', + details: {}, + createdBy: 1, + updatedBy: 1, + }, { + type: 'visual_design', + directProjectId: 1, + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, { + type: 'visual_design', + billingAccountId: 3, + name: 'test2', + description: 'completed project without copilot', + status: 'completed', + details: {}, + createdBy: 1, + updatedBy: 1, + }, { + type: 'generic', + billingAccountId: 4, + name: 'test2', + description: 'draft project without copilot', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, { + type: 'generic', + billingAccountId: 5, + name: 'test2', + description: 'active project without copilot', + status: 'active', + details: {}, + createdBy: 1, + updatedBy: 1, + }])) + .then(() => models.Project.findAll()) + .then((projects) => { + const project1 = projects[0]; + const project2 = projects[1]; + const operations = []; + operations.push(models.ProjectMember.bulkCreate([{ + userId: 40051331, + projectId: project1.id, + role: 'customer', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + userId: 40051332, + projectId: project1.id, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + userId: 40051333, + projectId: project1.id, + role: 'manager', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, { + userId: 40051332, + projectId: project2.id, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + userId: 40051331, + projectId: projects[2].id, + role: 'customer', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }])); + operations.push(models.ProjectAttachment.create({ + title: 'Spec', + projectId: project1.id, + description: 'specification', + filePath: 'projects/1/spec.pdf', + contentType: 'application/pdf', + createdBy: 1, + updatedBy: 1, + })); + return Promise.all(operations); + }) + .then(() => models.ProjectTemplate.bulkCreate([ + { + name: 'template 1', + key: 'key 1', + category: 'category 1', + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'template 2', + key: 'key 2', + category: 'category 2', + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }, + ])) + .then(() => models.ProductTemplate.bulkCreate([ + { + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'template 2', + productKey: 'productKey 2', + icon: 'http://example.com/icon2.ico', + brief: 'brief 2', + details: 'details 2', + aliases: {}, + template: {}, + createdBy: 3, + updatedBy: 4, + }, + ])) + .then(() => { + process.exit(0); + }) + .catch(() => process.exit(1)); diff --git a/src/util.js b/src/util.js index 86386add..efbcf3e1 100644 --- a/src/util.js +++ b/src/util.js @@ -209,30 +209,30 @@ _.assignIn(util, { getProjectAttachments: (req, projectId) => { let attachments = []; return models.ProjectAttachment.getActiveProjectAttachments(projectId) - .then((_attachments) => { - // if attachments were requested - if (attachments) { - attachments = _attachments; - } else { - return attachments; - } - // TODO consider using redis to cache attachments urls - const promises = []; - _.each(attachments, (a) => { - promises.push(util.getFileDownloadUrl(req, a.filePath)); - }); - return Promise.all(promises); - }) - .then((result) => { - // result is an array of 'tuples' => [[path, url], [path,url]] - // convert it to a map for easy lookup - const urls = _.fromPairs(result); - _.each(attachments, (at) => { - const a = at; - a.downloadUrl = urls[a.filePath]; - }); + .then((_attachments) => { + // if attachments were requested + if (attachments) { + attachments = _attachments; + } else { return attachments; + } + // TODO consider using redis to cache attachments urls + const promises = []; + _.each(attachments, (a) => { + promises.push(util.getFileDownloadUrl(req, a.filePath)); + }); + return Promise.all(promises); + }) + .then((result) => { + // result is an array of 'tuples' => [[path, url], [path,url]] + // convert it to a map for easy lookup + const urls = _.fromPairs(result); + _.each(attachments, (at) => { + const a = at; + a.downloadUrl = urls[a.filePath]; }); + return attachments; + }); }, getSystemUserToken: (logger, id = 'system') => { @@ -245,19 +245,19 @@ _.assignIn(util, { timeout: 4000, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }, - ) + ) .then(res => res.data.result.content.token); }, - /** - * Fetches the topcoder user details using the given JWT token. - * - * @param {Number} userId id of the user to be fetched - * @param {String} jwtToken JWT token of the admin user or JWT token of the user to be fecthed - * @param {Object} logger logger to be used for logging purposes - * - * @return {Promise} promise which resolves to the user's information - */ + /** + * Fetches the topcoder user details using the given JWT token. + * + * @param {Number} userId id of the user to be fetched + * @param {String} jwtToken JWT token of the admin user or JWT token of the user to be fecthed + * @param {Object} logger logger to be used for logging purposes + * + * @return {Promise} promise which resolves to the user's information + */ getTopcoderUser: (userId, jwtToken, logger) => { const httpClient = util.getHttpClient({ id: `userService_${userId}`, log: logger }); httpClient.defaults.timeout = 3000; @@ -266,7 +266,7 @@ _.assignIn(util, { httpClient.defaults.headers.common.Authorization = `Bearer ${jwtToken}`; return httpClient.get(`${config.identityServiceEndpoint}users/${userId}`).then((response) => { if (response.data && response.data.result - && response.data.result.status === 200 && response.data.result.content) { + && response.data.result.status === 200 && response.data.result.content) { return response.data.result.content; } return null; @@ -338,6 +338,20 @@ _.assignIn(util, { return Promise.reject(err); } }), + + /** + * Merge two JSON objects. For array fields, the target will be replaced by source. + * @param {Object} targetObj the target object + * @param {Object} sourceObj the source object + * @returns {Object} the merged object + */ + // eslint-disable-next-line consistent-return + mergeJsonObjects: (targetObj, sourceObj) => _.mergeWith(targetObj, sourceObj, (target, source) => { + // Overwrite the array + if (_.isArray(source)) { + return source; + } + }), }); export default util; diff --git a/swagger.yaml b/swagger.yaml index f195828f..14a15ee1 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -336,6 +336,256 @@ paths: schema: $ref: "#/definitions/UpdateProjectMemberBodyParam" + /projectTemplates: + get: + tags: + - projectTemplate + operationId: findProjectTemplates + security: + - Bearer: [] + description: Retreive all project templates. All user roles can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of project templates + schema: + $ref: "#/definitions/ProjectTemplateListResponse" + post: + tags: + - projectTemplate + operationId: addProjectTemplate + security: + - Bearer: [] + description: Create a project template + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/ProjectTemplateBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created project template + schema: + $ref: "#/definitions/ProjectTemplateResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /projectTemplates/{templateId}: + get: + tags: + - projectTemplate + description: Retrieve project template by id. All user roles can access this endpoint. + security: + - Bearer: [] + responses: + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: a project template + schema: + $ref: "#/definitions/ProjectTemplateResponse" + parameters: + - $ref: "#/parameters/templateIdParam" + operationId: getProjectTemplate + + patch: + tags: + - projectTemplate + operationId: updateProjectTemplate + security: + - Bearer: [] + description: Update a project template. Only connect manager, connect admin, and admin 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. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: Successfully updated project template. + schema: + $ref: "#/definitions/ProjectTemplateResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/templateIdParam" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/ProjectTemplateBodyParam" + + delete: + tags: + - projectTemplate + description: Remove an existing project template. Only connect manager, connect admin, and admin can access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/templateIdParam" + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: If project is not found + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Project template successfully removed + + + /productTemplates: + get: + tags: + - productTemplate + operationId: findProductTemplates + security: + - Bearer: [] + description: Retreive all product templates. All user roles can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of product templates + schema: + $ref: "#/definitions/ProductTemplateListResponse" + post: + tags: + - productTemplate + operationId: addProductTemplate + security: + - Bearer: [] + description: Create a product template + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/ProductTemplateBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created product template + schema: + $ref: "#/definitions/ProductTemplateResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /productTemplates/{templateId}: + get: + tags: + - productTemplate + description: Retrieve product template by id. All user roles can access this endpoint. + security: + - Bearer: [] + responses: + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: a product template + schema: + $ref: "#/definitions/ProductTemplateResponse" + parameters: + - $ref: "#/parameters/templateIdParam" + operationId: getProductTemplate + + patch: + tags: + - productTemplate + operationId: updateProductTemplate + security: + - Bearer: [] + description: Update a product template. Only connect manager, connect admin, and admin 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. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: Successfully updated product template. + schema: + $ref: "#/definitions/ProductTemplateResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/templateIdParam" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/ProductTemplateBodyParam" + + delete: + tags: + - productTemplate + description: Remove an existing product template. Only connect manager, connect admin, and admin can access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/templateIdParam" + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: If product is not found + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Product template successfully removed parameters: projectIdParam: @@ -345,6 +595,14 @@ parameters: required: true type: integer format: int64 + templateIdParam: + name: templateId + in: path + description: template identifier + required: true + type: integer + format: int64 + minimum: 1 offsetParam: name: offset description: "number of items to skip. Defaults to 0" @@ -938,3 +1196,245 @@ definitions: type: array items: $ref: "#/definitions/Project" + + ProjectTemplateRequest: + title: Project template request object + type: object + required: + - name + - key + - category + - scope + - phases + properties: + name: + type: string + description: the project template name + key: + type: string + description: the project template key + category: + type: string + description: the project template category + scope: + type: object + description: the project template scope + phases: + type: object + description: the project template phases + + ProjectTemplateBodyParam: + title: Project template body param + type: object + required: + - param + properties: + param: + $ref: "#/definitions/ProjectTemplateRequest" + + ProjectTemplate: + title: Project template object + allOf: + - type: object + required: + - id + - createdAt + - createdBy + - updatedAt + - updatedBy + properties: + id: + type: number + format: int64 + description: the id + createdAt: + type: string + description: Datetime (GMT) when object was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this object + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when object was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this object + readOnly: true + - $ref: "#/definitions/ProjectTemplateRequest" + + + ProjectTemplateResponse: + title: Single project template response object + type: object + properties: + id: + type: string + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + $ref: "#/definitions/ProjectTemplate" + + ProjectTemplateListResponse: + title: Project template list response object + type: object + properties: + id: + type: string + readOnly: true + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + type: array + items: + $ref: "#/definitions/ProjectTemplate" + + ProductTemplateRequest: + title: Product template request object + type: object + required: + - name + - key + - category + - scope + - phases + properties: + name: + type: string + description: the product template name + productKey: + type: string + description: the product template key + icon: + type: string + description: the product template icon + brief: + type: string + description: the product template brief + details: + type: string + description: the product template details + aliases: + type: object + description: the product template aliases + template: + type: object + description: the product template template + + ProductTemplateBodyParam: + title: Product template body param + type: object + required: + - param + properties: + param: + $ref: "#/definitions/ProductTemplateRequest" + + ProductTemplate: + title: Product template object + allOf: + - type: object + required: + - id + - createdAt + - createdBy + - updatedAt + - updatedBy + properties: + id: + type: number + format: int64 + description: the id + createdAt: + type: string + description: Datetime (GMT) when object was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this object + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when object was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this object + readOnly: true + - $ref: "#/definitions/ProductTemplateRequest" + + + ProductTemplateResponse: + title: Single product template response object + type: object + properties: + id: + type: string + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + $ref: "#/definitions/ProductTemplate" + + ProductTemplateListResponse: + title: Product template list response object + type: object + properties: + id: + type: string + readOnly: true + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + type: array + items: + $ref: "#/definitions/ProductTemplate" \ No newline at end of file