diff --git a/migrations/20190410_refactor_product_templates.sql b/migrations/20190410_refactor_product_templates.sql new file mode 100644 index 00000000..69b17b85 --- /dev/null +++ b/migrations/20190410_refactor_product_templates.sql @@ -0,0 +1,6 @@ +-- +-- product_templates +-- +ALTER TABLE product_templates ALTER COLUMN "template" DROP NOT NULL; + +ALTER TABLE product_templates ADD COLUMN "form" json; diff --git a/postman.json b/postman.json index a157e443..b4b0a1b6 100644 --- a/postman.json +++ b/postman.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "db83f8a1-5b3f-4276-a371-aa3c3497542d", + "_postman_id": "d9ea7b0f-1d2c-4d48-a693-fe7b51b1e2ea", "name": "tc-project-service", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -3081,7 +3081,106 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\": {\r\n \"name\": \"name 1\",\r\n \"productKey\": \"productKey 1\",\r\n \"category\": \"key1\",\r\n \"icon\": \"http://example.com/icon1.ico\",\r\n \"brief\": \"brief 1\",\r\n \"details\": \"details 1\",\r\n \"aliases\": [\"product key 1\", \"product_key_1\"],\r\n \"template\": {\r\n \"template1\": {\r\n \"name\": \"template 1\",\r\n \"details\": {\r\n \"anyDetails\": \"any details 1\"\r\n },\r\n \"others\": [\"others 11\", \"others 12\"]\r\n },\r\n \"template2\": {\r\n \"name\": \"template 2\",\r\n \"details\": {\r\n \"anyDetails\": \"any details 2\"\r\n },\r\n \"others\": [\"others 21\", \"others 22\"]\r\n }\r\n }\r\n }\r\n }" + "raw": "{\r\n \"param\": {\r\n \"name\": \"name 1\",\r\n \"productKey\": \"productKey 1\",\r\n \"category\": \"key1\",\r\n \"icon\": \"http://example.com/icon1.ico\",\r\n \"brief\": \"brief 1\",\r\n \"details\": \"details 1\",\r\n \"aliases\": [\"product key 1\", \"product_key_1\"],\r\n \"template\": {\r\n \"template1\": {\r\n \"name\": \"template 1\",\r\n \"details\": {\r\n \"anyDetails\": \"any details 1\"\r\n },\r\n \"others\": [\"others 11\", \"others 12\"]\r\n },\r\n \"template2\": {\r\n \"name\": \"template 2\",\r\n \"details\": {\r\n \"anyDetails\": \"any details 2\"\r\n },\r\n \"others\": [\"others 21\", \"others 22\"]\r\n }\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/metadata/productTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "metadata", + "productTemplates" + ] + } + }, + "response": [] + }, + { + "name": "Create product template with form", + "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\": \"name 1\",\r\n \"productKey\": \"productKey 1\",\r\n \"category\": \"key1\",\r\n \"icon\": \"http://example.com/icon1.ico\",\r\n \"brief\": \"brief 1\",\r\n \"details\": \"details 1\",\r\n \"aliases\": [\"product key 1\", \"product_key_1\"],\r\n \"form\": {\r\n\t\t\"key\": \"dev\",\r\n\t\t\"version\": 1\r\n\t}\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/metadata/productTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "metadata", + "productTemplates" + ] + } + }, + "response": [] + }, + { + "name": "Create product template with wrong form key", + "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\": \"name 1\",\r\n \"productKey\": \"productKey 1\",\r\n \"category\": \"key1\",\r\n \"icon\": \"http://example.com/icon1.ico\",\r\n \"brief\": \"brief 1\",\r\n \"details\": \"details 1\",\r\n \"aliases\": [\"product key 1\", \"product_key_1\"],\r\n \"form\": {\r\n\t\t\"key\": \"wrong-key\"\r\n\t}\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/metadata/productTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "metadata", + "productTemplates" + ] + } + }, + "response": [] + }, + { + "name": "Create product template with wrong model version", + "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\": \"name 1\",\r\n \"productKey\": \"productKey 1\",\r\n \"category\": \"key1\",\r\n \"icon\": \"http://example.com/icon1.ico\",\r\n \"brief\": \"brief 1\",\r\n \"details\": \"details 1\",\r\n \"aliases\": [\"product key 1\", \"product_key_1\"],\r\n \"form\": {\r\n\t\t\"key\": \"dev\",\r\n\t\t\"version\": 1123\r\n\t}\r\n }\r\n}" }, "url": { "raw": "{{api-url}}/v4/projects/metadata/productTemplates", @@ -3232,6 +3331,117 @@ } }, "response": [] + }, + { + "name": "Upgrade a product template with form", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"form\": {\r\n \t\"key\": \"dev\",\t\r\n \t\"version\": 2\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/metadata/productTemplates/2/upgrade", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "metadata", + "productTemplates", + "2", + "upgrade" + ] + } + }, + "response": [] + }, + { + "name": "Upgrade a product template with wrong model version", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"form\": {\r\n \t\"key\": \"dev\",\t\r\n \t\"version\": 1234\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/metadata/productTemplates/1/upgrade", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "metadata", + "productTemplates", + "1", + "upgrade" + ] + } + }, + "response": [] + }, + { + "name": "Upgrade a product template without define form", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{ \r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/metadata/productTemplates/3/upgrade", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "metadata", + "productTemplates", + "3", + "upgrade" + ] + } + }, + "response": [] } ] }, diff --git a/src/models/productTemplate.js b/src/models/productTemplate.js index 9149ce04..65252e4e 100644 --- a/src/models/productTemplate.js +++ b/src/models/productTemplate.js @@ -14,7 +14,8 @@ module.exports = (sequelize, DataTypes) => { 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 }, + template: { type: DataTypes.JSON, allowNull: true }, + form: { type: DataTypes.JSON, allowNull: true }, deletedAt: DataTypes.DATE, disabled: { type: DataTypes.BOOLEAN, defaultValue: false }, hidden: { type: DataTypes.BOOLEAN, defaultValue: false }, diff --git a/src/permissions/index.js b/src/permissions/index.js index c68c44a4..5acab2be 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -35,6 +35,7 @@ module.exports = () => { Authorizer.setPolicy('productTemplate.create', projectAdmin); Authorizer.setPolicy('productTemplate.edit', projectAdmin); + Authorizer.setPolicy('productTemplate.upgrade', projectAdmin); Authorizer.setPolicy('productTemplate.delete', projectAdmin); Authorizer.setPolicy('projectTemplate.upgrade', projectAdmin); Authorizer.setPolicy('productTemplate.view', true); diff --git a/src/routes/index.js b/src/routes/index.js index 045384bf..4e8176af 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -43,6 +43,8 @@ router.route('/v4/projects/metadata/productTemplates') .get(require('./productTemplates/list')); router.route('/v4/projects/metadata/productTemplates/:templateId(\\d+)') .get(require('./productTemplates/get')); +router.route('/v4/projects/metadata/productTemplates/:templateId(\\d+)/upgrade') + .post(require('./productTemplates/upgrade')); router.route('/v4/projects/metadata/projectTypes') .get(require('./projectTypes/list')); @@ -50,7 +52,7 @@ router.route('/v4/projects/metadata/projectTypes/:key') .get(require('./projectTypes/get')); router.route('/v4/projects/metadata/projectTemplates/:templateId(\\d+)/upgrade') -.post(require('./projectTemplates/upgrade')); + .post(require('./projectTemplates/upgrade')); router.route('/v4/projects/metadata/orgConfig') .get(require('./orgConfig/list')); diff --git a/src/routes/metadata/list.js b/src/routes/metadata/list.js index 7a4b0388..3358350f 100644 --- a/src/routes/metadata/list.js +++ b/src/routes/metadata/list.js @@ -30,27 +30,39 @@ function getUsedModel() { attributes: { exclude: ['deletedAt', 'deletedBy'] }, raw: true, }; - return models.ProjectTemplate.findAll(query) - .then((templates) => { - templates.forEach((template) => { - const { form, planConfig, priceConfig } = template; - if ((form) && (form.key) && (form.version)) { - modelUsed.form[form.key] = modelUsed.form[form.key] ? modelUsed.form[form.key] : {}; - modelUsed.form[form.key][form.version] = true; - } - if ((priceConfig) && (priceConfig.key) && (priceConfig.version)) { - modelUsed.priceConfig[priceConfig.key] = modelUsed.priceConfig[priceConfig.key] ? - modelUsed.priceConfig[priceConfig.key] : {}; - modelUsed.priceConfig[priceConfig.key][priceConfig.version] = true; - } - if ((planConfig) && (planConfig.key) && (planConfig.version)) { - modelUsed.planConfig[planConfig.key] = modelUsed.planConfig[planConfig.key] ? - modelUsed.planConfig[planConfig.key] : {}; - modelUsed.planConfig[planConfig.key][planConfig.version] = true; - } - }); - return Promise.resolve(modelUsed); - }); + + return Promise.all([ + models.ProjectTemplate.findAll(query), + models.ProductTemplate.findAll(query), + ]).then(([projectTemplates, productTemplates]) => { + projectTemplates.forEach((template) => { + const { form, planConfig, priceConfig } = template; + if ((form) && (form.key) && (form.version)) { + modelUsed.form[form.key] = modelUsed.form[form.key] ? modelUsed.form[form.key] : {}; + modelUsed.form[form.key][form.version] = true; + } + if ((priceConfig) && (priceConfig.key) && (priceConfig.version)) { + modelUsed.priceConfig[priceConfig.key] = modelUsed.priceConfig[priceConfig.key] ? + modelUsed.priceConfig[priceConfig.key] : {}; + modelUsed.priceConfig[priceConfig.key][priceConfig.version] = true; + } + if ((planConfig) && (planConfig.key) && (planConfig.version)) { + modelUsed.planConfig[planConfig.key] = modelUsed.planConfig[planConfig.key] ? + modelUsed.planConfig[planConfig.key] : {}; + modelUsed.planConfig[planConfig.key][planConfig.version] = true; + } + }); + + productTemplates.forEach((template) => { + const { form } = template; + if ((form) && (form.key) && (form.version)) { + modelUsed.form[form.key] = modelUsed.form[form.key] ? modelUsed.form[form.key] : {}; + modelUsed.form[form.key][form.version] = true; + } + }); + + return Promise.resolve(modelUsed); + }); } diff --git a/src/routes/metadata/list.spec.js b/src/routes/metadata/list.spec.js index 6e037309..6bbf9cb8 100644 --- a/src/routes/metadata/list.spec.js +++ b/src/routes/metadata/list.spec.js @@ -38,7 +38,8 @@ const productTemplates = [ brief: 'brief 1', details: 'details 1', aliases: {}, - template: {}, + form: { key: 'productKey 1', version: 1 }, + template: null, createdBy: 1, updatedBy: 2, }, @@ -107,6 +108,30 @@ const forms = [ createdBy: 1, updatedBy: 1, }, + { + key: 'productKey 1', + config: { + questions: [{ + id: 'appDefinition', + title: 'Sample Project', + required: true, + description: 'Please answer a few basic questions', + subSections: [{ + id: 'projectName', + required: true, + validationError: 'Please provide a name for your project', + fieldName: 'name', + description: '', + title: 'Project Name', + type: 'project-name', + }], + }], + }, + version: 2, + revision: 1, + createdBy: 1, + updatedBy: 1, + }, ]; const priceConfigs = [ { @@ -198,7 +223,7 @@ describe('GET all metadata', () => { resJson.milestoneTemplates.should.have.length(1); resJson.projectTypes.should.have.length(1); resJson.productCategories.should.have.length(1); - resJson.forms.should.have.length(1); + resJson.forms.should.have.length(2); resJson.planConfigs.should.have.length(1); resJson.priceConfigs.should.have.length(1); @@ -225,7 +250,7 @@ describe('GET all metadata', () => { resJson.milestoneTemplates.should.have.length(1); resJson.projectTypes.should.have.length(1); resJson.productCategories.should.have.length(1); - resJson.forms.should.have.length(2); + resJson.forms.should.have.length(3); resJson.planConfigs.should.have.length(2); resJson.priceConfigs.should.have.length(2); done(); diff --git a/src/routes/productTemplates/create.js b/src/routes/productTemplates/create.js index aa9e4b98..87a70b91 100644 --- a/src/routes/productTemplates/create.js +++ b/src/routes/productTemplates/create.js @@ -23,7 +23,8 @@ const schema = { brief: Joi.string().max(45).required(), details: Joi.string().max(255).required(), aliases: Joi.array().required(), - template: Joi.object().required(), + template: Joi.object(), + form: Joi.object(), disabled: Joi.boolean().optional(), hidden: Joi.boolean().optional(), isAddOn: Joi.boolean().optional(), @@ -42,16 +43,22 @@ module.exports = [ permissions('productTemplate.create'), fieldLookupValidation(models.ProductCategory, 'key', 'body.param.category', 'Category'), (req, res, next) => { - const entity = _.assign(req.body.param, { - createdBy: req.authUser.userId, - updatedBy: req.authUser.userId, - }); + const param = req.body.param; + const { form } = param; + return util.checkModel(form, 'Form', models.Form, 'product template') + .then(() => { + const entity = _.assign(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)); + 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); }) .catch(next); }, diff --git a/src/routes/productTemplates/create.spec.js b/src/routes/productTemplates/create.spec.js index fc367625..2da8feba 100644 --- a/src/routes/productTemplates/create.spec.js +++ b/src/routes/productTemplates/create.spec.js @@ -12,22 +12,49 @@ import models from '../../models'; const should = chai.should(); describe('CREATE product template', () => { - before((done) => { - testUtil.clearDb() - .then(() => models.ProductCategory.bulkCreate([ - { - key: 'generic', - displayName: 'Generic', - icon: 'http://example.com/icon1.ico', - question: 'question 1', - info: 'info 1', - aliases: ['key-1', 'key_1'], - createdBy: 1, - updatedBy: 1, - }, - ])) - .then(() => done()); - }); + const productCategories = [ + { + key: 'generic', + displayName: 'Generic', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + createdBy: 1, + updatedBy: 1, + }, + ]; + + const forms = [ + { + key: 'dev', + config: { + test: 'test1', + }, + version: 1, + revision: 1, + createdBy: 1, + updatedBy: 1, + }, + { + key: 'dev', + config: { + test: 'test2', + }, + version: 2, + revision: 1, + createdBy: 1, + updatedBy: 1, + }, + ]; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductCategory.bulkCreate(productCategories)) + .then(() => models.Form.create(forms[0])) + .then(() => models.Form.create(forms[1])) + .then(() => Promise.resolve()), + ); + after(testUtil.clearDb); describe('POST /projects/metadata/productTemplates', () => { const body = { @@ -62,6 +89,19 @@ describe('CREATE product template', () => { }, }; + const bodyWithForm = _.cloneDeep(body); + bodyWithForm.param.form = { + version: 1, + key: 'dev', + }; + delete bodyWithForm.param.template; + + const bodyInvalidForm = _.cloneDeep(body); + bodyInvalidForm.param.form = { + version: 1, + key: 'wrongKey', + }; + it('should return 403 if user is not authenticated', (done) => { request(server) .post('/v4/projects/metadata/productTemplates') @@ -193,5 +233,49 @@ describe('CREATE product template', () => { done(); }); }); + + it('should return 201 with form data', (done) => { + request(server) + .post('/v4/projects/metadata/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(bodyWithForm) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson.id); + resJson.name.should.be.eql(bodyWithForm.param.name); + resJson.productKey.should.be.eql(bodyWithForm.param.productKey); + resJson.category.should.be.eql(bodyWithForm.param.category); + resJson.icon.should.be.eql(bodyWithForm.param.icon); + resJson.brief.should.be.eql(bodyWithForm.param.brief); + resJson.details.should.be.eql(bodyWithForm.param.details); + resJson.aliases.should.be.eql(bodyWithForm.param.aliases); + resJson.form.should.be.eql(bodyWithForm.param.form); + resJson.disabled.should.be.eql(true); + resJson.hidden.should.be.eql(true); + + 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 422 when form is invalid', (done) => { + request(server) + .post('/v4/projects/metadata/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(bodyInvalidForm) + .expect(422, done); + }); }); }); diff --git a/src/routes/productTemplates/list.spec.js b/src/routes/productTemplates/list.spec.js index f975a3a9..d872be8e 100644 --- a/src/routes/productTemplates/list.spec.js +++ b/src/routes/productTemplates/list.spec.js @@ -15,7 +15,7 @@ const validateProductTemplates = (count, resJson, expectedTemplates) => { resJson.should.have.length(count); resJson.forEach((pt, idx) => { pt.should.have.all.keys('id', 'name', 'productKey', 'category', 'subCategory', 'icon', 'brief', 'details', - 'aliases', 'template', 'disabled', 'hidden', 'isAddOn', 'createdBy', 'createdAt', 'updatedBy', 'updatedAt'); + 'aliases', 'template', 'disabled', 'form', 'hidden', 'isAddOn', 'createdBy', 'createdAt', 'updatedBy', 'updatedAt'); pt.should.not.have.all.keys('deletedAt', 'deletedBy'); pt.name.should.be.eql(expectedTemplates[idx].name); pt.productKey.should.be.eql(expectedTemplates[idx].productKey); diff --git a/src/routes/productTemplates/update.js b/src/routes/productTemplates/update.js index ad245b8e..5a313424 100644 --- a/src/routes/productTemplates/update.js +++ b/src/routes/productTemplates/update.js @@ -27,6 +27,7 @@ const schema = { details: Joi.string().max(255), aliases: Joi.array(), template: Joi.object(), + form: Joi.object(), disabled: Joi.boolean().optional(), hidden: Joi.boolean().optional(), isAddOn: Joi.boolean().optional(), @@ -45,34 +46,41 @@ module.exports = [ permissions('productTemplate.edit'), fieldLookupValidation(models.ProductCategory, 'key', 'body.param.category', 'Category'), (req, res, next) => { - const entityToUpdate = _.assign(req.body.param, { - updatedBy: req.authUser.userId, - }); + const param = req.body.param; + const { form } = param; + return util.checkModel(form, 'Form', models.Form, 'product template') + .then(() => { + 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); - } + 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); + if (entityToUpdate.template) { + // Merge JSON fields + entityToUpdate.template = util.mergeJsonObjects(productTemplate.template, entityToUpdate.template); + } - return productTemplate.update(entityToUpdate); - }) - .then((productTemplate) => { - res.json(util.wrapResponse(req.id, productTemplate)); - return Promise.resolve(); + return productTemplate.update(entityToUpdate); + }) + .then((productTemplate) => { + res.json(util.wrapResponse(req.id, productTemplate)); + return Promise.resolve(); + }) + .catch(next); }) .catch(next); }, diff --git a/src/routes/productTemplates/update.spec.js b/src/routes/productTemplates/update.spec.js index a1508e2c..c17d9427 100644 --- a/src/routes/productTemplates/update.spec.js +++ b/src/routes/productTemplates/update.spec.js @@ -1,6 +1,7 @@ /** * Tests for get.js */ +import _ from 'lodash'; import chai from 'chai'; import request from 'supertest'; @@ -43,9 +44,34 @@ describe('UPDATE product template', () => { updatedBy: 2, }; + const forms = [ + { + key: 'dev', + config: { + test: 'test1', + }, + version: 1, + revision: 1, + createdBy: 1, + updatedBy: 1, + }, + { + key: 'dev', + config: { + test: 'test2', + }, + version: 2, + revision: 1, + createdBy: 1, + updatedBy: 1, + }, + ]; + let templateId; beforeEach(() => testUtil.clearDb() + .then(() => models.Form.create(forms[0])) + .then(() => models.Form.create(forms[1])) .then(() => models.ProductCategory.bulkCreate([ { key: 'generic', @@ -107,6 +133,18 @@ describe('UPDATE product template', () => { }, }; + const bodyWithForm = _.cloneDeep(body); + bodyWithForm.param.form = { + version: 1, + key: 'dev', + }; + + const bodyInvalidForm = _.cloneDeep(body); + bodyInvalidForm.param.form = { + version: 1, + key: 'wrongKey', + }; + it('should return 403 if user is not authenticated', (done) => { request(server) .patch(`/v4/projects/metadata/productTemplates/${templateId}`) @@ -249,5 +287,30 @@ describe('UPDATE product template', () => { .expect(200) .end(done); }); + + it('should return 200 when update form', (done) => { + request(server) + .patch(`/v4/projects/metadata/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(bodyWithForm) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.form.should.be.eql(bodyWithForm.param.form); + done(); + }); + }); + + it('should return 422 when form is invalid', (done) => { + request(server) + .patch(`/v4/projects/metadata/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(bodyInvalidForm) + .expect(422, done); + }); }); }); diff --git a/src/routes/productTemplates/upgrade.js b/src/routes/productTemplates/upgrade.js new file mode 100644 index 00000000..c78fe827 --- /dev/null +++ b/src/routes/productTemplates/upgrade.js @@ -0,0 +1,75 @@ +/** + * API to add a new version of form + */ +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({ + form: Joi.object().keys({ + version: Joi.number().integer().positive().required(), + key: Joi.string().required(), + }).optional(), + }).optional(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productTemplate.upgrade'), + (req, res, next) => { + models.sequelize.transaction( + () => models.ProductTemplate.findOne({ + where: { + id: req.params.templateId, + }, + }).then(async (productTemplate) => { + if (_.isNil(productTemplate)) { + const apiErr = new Error(`product template not found for id ${req.body.param.templateId}`); + apiErr.status = 404; + throw apiErr; + } + + if (_.isNil(productTemplate.template)) { + const apiErr = new Error('Current product template\'s template is null'); + apiErr.status = 422; + throw apiErr; + } + + let newForm = {}; + if (_.isNil(req.body.param.form)) { + const { productKey, template = {} } = productTemplate; + const { version } = await models.Form.createNewVersion(productKey, template, req.authUser.userId); + newForm = { + version, + key: productKey, + }; + } else { + newForm = req.body.param.form; + await util.checkModel(newForm, 'Form', models.Form, 'product template'); + } + // update product template with new form data + const updatePayload = { + template: null, + form: newForm, + updatedBy: req.authUser.userId, + }; + + const newProductTemplate = await productTemplate.update(updatePayload); + const response = util.wrapResponse( + req.id, + _.omit(newProductTemplate.toJSON(), 'deletedAt', 'deletedBy'), + 1, + 201, + ); + return res.status(201).json(response); + }).catch(next)); + }, +]; diff --git a/src/routes/productTemplates/upgrade.spec.js b/src/routes/productTemplates/upgrade.spec.js new file mode 100644 index 00000000..0cdfa5d4 --- /dev/null +++ b/src/routes/productTemplates/upgrade.spec.js @@ -0,0 +1,313 @@ +/** + * 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('UPGRADE product template', () => { + const productTemplate = { + name: 'name 1', + productKey: 'productKey1', + category: 'generic', + subCategory: 'generic', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: ['productTemplate-1', 'productTemplate_1'], + disabled: true, + hidden: true, + 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, + }; + + const productTemplateMissed = { + name: 'name 2', + productKey: 'productKey2', + category: 'generic', + subCategory: 'generic', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: ['productTemplate-1', 'productTemplate_1'], + disabled: true, + hidden: true, + createdBy: 1, + updatedBy: 2, + }; + + let templateId; + let missingTemplateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductCategory.bulkCreate([ + { + key: 'generic', + displayName: 'Generic', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + createdBy: 1, + updatedBy: 1, + }, + { + key: 'concrete', + displayName: 'Concrete', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + createdBy: 1, + updatedBy: 1, + }, + ])) + .then(() => { + const config = { + questions: [{ + id: 'appDefinition', + title: 'Sample Project', + required: true, + description: 'Please answer a few basic questions', + subSections: [{ + id: 'projectName', + required: true, + validationError: 'Please provide a name for your project', + fieldName: 'name', + description: '', + title: 'Project Name', + type: 'project-name', + }, { + id: 'notes', + fieldName: 'details.appDefinition.notes', + title: 'Notes', + description: 'Add any other important information', + type: 'notes', + }], + }], + }; + models.Form.bulkCreate([ + { + key: 'newKey', + version: 1, + revision: 1, + config, + createdBy: 1, + updatedBy: 1, + }, + ]); + }) + .then(() => models.ProductTemplate.create(productTemplate)) + .then((createdTemplate) => { + templateId = createdTemplate.id; + }) + .then(() => models.ProductTemplate.create(productTemplateMissed)) + .then((createdTemplate) => { + missingTemplateId = createdTemplate.id; + }), + ); + after(testUtil.clearDb); + + describe('POST /projects/metadata/productTemplates/{templateId}/upgrade', () => { + const body = { + param: { + form: { + key: 'newKey', + version: 1, + }, + }, + }; + + const bodyInvalidForm = { + param: { + form: { + key: 'wrongKey', + version: 1, + }, + }, + }; + + const emptyBody = { + param: { + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post(`/v4/projects/metadata/productTemplates/${templateId}/upgrade`) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post(`/v4/projects/metadata/productTemplates/${templateId}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post(`/v4/projects/metadata/productTemplates/${templateId}/upgrade`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 403 for connect manager', (done) => { + request(server) + .post(`/v4/projects/metadata/productTemplates/${templateId}/upgrade`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403, done); + }); + + it('should return 422 for invalid request', (done) => { + const invalidBody = { + param: { + form: { + key: 'notvalid', + version: 1, + }, + }, + }; + + request(server) + .post(`/v4/projects/metadata/productTemplates/${templateId}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect(422, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .post('/v4/projects/metadata/productTemplates/1234/upgrade') + .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) + .post(`/v4/projects/metadata/productTemplates/${templateId}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .post(`/v4/projects/metadata/productTemplates/${templateId}/upgrade`) + .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); + should.not.exist(resJson.template); + + resJson.form.should.be.eql({ + key: 'newKey', + version: 1, + }); + + 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 create new version of model if param not given model key and version', (done) => { + request(server) + .post(`/v4/projects/metadata/productTemplates/${templateId}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(emptyBody) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + + should.not.exist(resJson.scope); + should.not.exist(resJson.phases); + + resJson.form.should.be.eql({ + key: 'productKey1', + version: 1, + }); + + resJson.createdBy.should.be.eql(productTemplate.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 422 when form is invalid', (done) => { + request(server) + .post(`/v4/projects/metadata/productTemplates/${templateId}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(bodyInvalidForm) + .expect(422, done); + }); + + it('should return 422 when template is missing', (done) => { + request(server) + .post(`/v4/projects/metadata/productTemplates/${missingTemplateId}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(422, done); + }); + }); +}); diff --git a/src/util.js b/src/util.js index 853e71c4..9e2e464c 100644 --- a/src/util.js +++ b/src/util.js @@ -481,6 +481,56 @@ _.assignIn(util, { * @return {Array} tpcoder project members */ getTopcoderProjectMembers: members => _(members).filter(m => m.role !== PROJECT_MEMBER_ROLE.CUSTOMER), + + /** + * Check if the following model exist + * @param {Object} keyInfo key information, it includes version and key + * @param {String} modelName name of model + * @param {Object} model model that will be checked + * @param {String} referredEntityName entity that referred by this model + * @return {Promise} promise whether the record exists or not + */ + checkModel: (keyInfo, modelName, model, referredEntityName) => { + if (_.isNil(keyInfo)) { + return Promise.resolve(null); + } + + const { version, key } = keyInfo; + let errorMessage = ''; + + if (!_.isNil(version) && !_.isNil(key)) { + errorMessage = `${modelName} with key ${key} and version ${version}` + + ` referred in the ${referredEntityName} is not found`; + return (model.findOne({ + where: { + key, + version, + }, + })).then((record) => { + if (_.isNil(record)) { + const apiErr = new Error(errorMessage); + apiErr.status = 422; + throw apiErr; + } + }); + } else if (_.isNil(version) && !_.isNil(key)) { + errorMessage = `${modelName} with key ${key}` + + ` referred in ${referredEntityName} is not found`; + return (model.findOne({ + where: { + key, + }, + })).then((record) => { + if (_.isNil(record)) { + const apiErr = new Error(errorMessage); + apiErr.status = 422; + throw apiErr; + } + }); + } + + return Promise.resolve(null); + }, }); export default util; diff --git a/swagger.yaml b/swagger.yaml index a7085701..a74dbb94 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -825,12 +825,12 @@ paths: description: If project is not found schema: $ref: '#/definitions/ErrorModel' - '/projects/metadata/productTemplates/{templateId}/upgrade': + '/projects/metadata/projectTemplates/{templateId}/upgrade': post: tags: - - productTemplate + - projectTemplate description: >- - upgrade projectTemplate model, + upgrade projectTemplate model security: - Bearer: [] parameters: @@ -842,13 +842,13 @@ paths: $ref: '#/definitions/ProjectTemplateUpgradeBodyParam' responses: '200': - description: Product template successfully upgrade + description: Project template successfully upgrade '403': description: No permission or wrong token schema: $ref: '#/definitions/ErrorModel' '404': - description: If product template is not found + description: If project template is not found schema: $ref: '#/definitions/ErrorModel' '422': @@ -987,6 +987,40 @@ paths: description: If product is not found schema: $ref: '#/definitions/ErrorModel' + '/projects/metadata/productTemplates/{templateId}/upgrade': + post: + tags: + - productTemplate + description: >- + upgrade productTemplate model + security: + - Bearer: [] + parameters: + - $ref: '#/parameters/templateIdParam' + - in: body + name: body + required: true + schema: + $ref: '#/definitions/ProductTemplateUpgradeBodyParam' + responses: + '200': + description: Product template successfully upgraded + '403': + description: No permission or wrong token + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: If product template is not found + schema: + $ref: '#/definitions/ErrorModel' + '422': + description: Invalid input + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Server Error + schema: + $ref: '#/definitions/ErrorModel' /projects/metadata/productCategories: get: tags: @@ -3555,7 +3589,7 @@ definitions: priceConfig: $ref: '#/definitions/VersionModelParam' planConfig: - $ref: '#/definitions/VersionModelParam' + $ref: '#/definitions/VersionModelParam' ProjectTemplateUpgradeBodyParam: title: Project template type: object @@ -3564,11 +3598,20 @@ definitions: type: object properties: form: - $ref: '#/definitions/VersionModelParam' + $ref: '#/definitions/VersionModelParam' priceConfig: - $ref: '#/definitions/VersionModelParam' + $ref: '#/definitions/VersionModelParam' planConfig: - $ref: '#/definitions/VersionModelParam' + $ref: '#/definitions/VersionModelParam' + ProductTemplateUpgradeBodyParam: + title: Product template + type: object + properties: + param: + type: object + properties: + form: + $ref: '#/definitions/VersionModelParam' VersionModelParam: title: version model param type: object @@ -3703,6 +3746,8 @@ definitions: template: type: object description: the product template template + form: + $ref: '#/definitions/VersionModelParam' isAddOn: type: boolean description: the flag that shows if the product template is an add on