diff --git a/app/jest.config.js b/app/jest.config.js index 0eac637a6..b44890831 100755 --- a/app/jest.config.js +++ b/app/jest.config.js @@ -1,7 +1,7 @@ module.exports = { clearMocks: true, collectCoverage: true, - collectCoverageFrom: ['src/**/*.js', '!src/db/migrations/*.js', '!src/db/seeds/*.js', '!frontend/**/*.*'], + collectCoverageFrom: ['src/**/*.js', '!src/db/migrations/*.js', '!src/db/seeds/*.js', '!src/forms/common/models/(tables|views)/*.js', '!frontend/**/*.*'], moduleFileExtensions: ['js', 'json'], moduleNameMapper: { '^~/(.*)$': '/src/$1', diff --git a/app/src/db/migrations/20240403192833_044-document-templates.js b/app/src/db/migrations/20240403192833_044-document-templates.js new file mode 100644 index 000000000..af3d1361e --- /dev/null +++ b/app/src/db/migrations/20240403192833_044-document-templates.js @@ -0,0 +1,120 @@ +const uuid = require('uuid'); + +const stamps = require('../stamps'); +const { Permissions, Roles } = require('../../forms/common/constants'); + +const CREATED_BY = 'migration-044'; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return Promise.resolve().then(() => + knex.schema + .createTable('document_template', (table) => { + table.uuid('id').primary(); + table.uuid('formId').references('id').inTable('form').notNullable().index(); + table.boolean('active').defaultTo(true); + table.string('filename', 1024).notNullable(); + table.binary('template').notNullable(); + stamps(knex, table); + }) + + .then(() => + knex.schema.alterTable('form', (table) => { + table.boolean('enableDocumentTemplates').defaultTo(false).comment('Allow document templates to be stored'); + }) + ) + + .then(() => { + const permission = { + createdBy: CREATED_BY, + code: Permissions.DOCUMENT_TEMPLATE_CREATE, + display: 'Document Template Create', + description: 'Can create document templates for a form', + active: true, + }; + return knex('permission').insert(permission); + }) + .then(() => { + const permission = { + createdBy: CREATED_BY, + code: Permissions.DOCUMENT_TEMPLATE_DELETE, + display: 'Document Template Delete', + description: 'Can delete document templates for a form', + active: true, + }; + return knex('permission').insert(permission); + }) + .then(() => { + const permission = { + createdBy: CREATED_BY, + code: Permissions.DOCUMENT_TEMPLATE_READ, + display: 'Document Template Read', + description: 'Can view document templates for a form', + active: true, + }; + return knex('permission').insert(permission); + }) + + .then(() => { + const rolePermission = { + id: uuid.v4(), + createdBy: CREATED_BY, + role: Roles.OWNER, + permission: Permissions.DOCUMENT_TEMPLATE_CREATE, + }; + return knex('role_permission').insert(rolePermission); + }) + .then(() => { + const rolePermission = { + id: uuid.v4(), + createdBy: CREATED_BY, + role: Roles.OWNER, + permission: Permissions.DOCUMENT_TEMPLATE_DELETE, + }; + return knex('role_permission').insert(rolePermission); + }) + .then(() => { + const rolePermission = { + id: uuid.v4(), + createdBy: CREATED_BY, + role: Roles.OWNER, + permission: Permissions.DOCUMENT_TEMPLATE_READ, + }; + return knex('role_permission').insert(rolePermission); + }) + ); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return Promise.resolve() + .then(() => + knex('role_permission') + .where({ + createdBy: CREATED_BY, + }) + .del() + ) + + .then(() => + knex('permission') + .where({ + createdBy: CREATED_BY, + }) + .del() + ) + + .then(() => + knex.schema.alterTable('form', (table) => { + table.dropColumn('enableDocumentTemplates'); + }) + ) + + .then(() => knex.schema.dropTableIfExists('document_template')); +}; diff --git a/app/src/forms/common/constants.js b/app/src/forms/common/constants.js index e547e846f..9292b508c 100644 --- a/app/src/forms/common/constants.js +++ b/app/src/forms/common/constants.js @@ -14,6 +14,9 @@ module.exports = Object.freeze({ REMINDER_FORM_NOT_FILL: 'formNotFill', }, Permissions: { + DOCUMENT_TEMPLATE_CREATE: 'document_template_create', + DOCUMENT_TEMPLATE_DELETE: 'document_template_delete', + DOCUMENT_TEMPLATE_READ: 'document_template_read', EMAIL_TEMPLATE_READ: 'email_template_read', EMAIL_TEMPLATE_UPDATE: 'email_template_update', FORM_API_CREATE: 'form_api_create', diff --git a/app/src/forms/common/middleware/validateParameter.js b/app/src/forms/common/middleware/validateParameter.js index ced072cc0..fd8b6602f 100644 --- a/app/src/forms/common/middleware/validateParameter.js +++ b/app/src/forms/common/middleware/validateParameter.js @@ -18,6 +18,32 @@ const _validateUuid = (parameter, parameterName) => { } }; +/** + * Validates that the :documentTemplateId route parameter exists and is a UUID. + * This validator requires that the :formId route parameter also exists. + * + * @param {*} req the Express object representing the HTTP request + * @param {*} _res the Express object representing the HTTP response - unused + * @param {*} next the Express chaining function + * @param {*} documentTemplateId the :documentTemplateId value from the route + */ +const validateDocumentTemplateId = async (req, _res, next, documentTemplateId) => { + try { + _validateUuid(documentTemplateId, 'documentTemplateId'); + + const documentTemplate = await formService.documentTemplateRead(documentTemplateId); + if (!documentTemplate || documentTemplate.formId !== req.params.formId) { + throw new Problem(404, { + detail: 'documentTemplateId does not exist on this form', + }); + } + + next(); + } catch (error) { + next(error); + } +}; + /** * Validates that the :formId route parameter exists and is a UUID. * @@ -89,6 +115,7 @@ const validateFormVersionId = async (req, _res, next, formVersionId) => { }; module.exports = { + validateDocumentTemplateId, validateFormId, validateFormVersionId, validateFormVersionDraftId, diff --git a/app/src/forms/common/models/index.js b/app/src/forms/common/models/index.js index 617f949be..4d8d723af 100644 --- a/app/src/forms/common/models/index.js +++ b/app/src/forms/common/models/index.js @@ -1,5 +1,6 @@ module.exports = { // Tables + DocumentTemplate: require('./tables/documentTemplate'), FileStorage: require('./tables/fileStorage'), Form: require('./tables/form'), FormApiKey: require('./tables/formApiKey'), diff --git a/app/src/forms/common/models/tables/documentTemplate.js b/app/src/forms/common/models/tables/documentTemplate.js new file mode 100644 index 000000000..9bfd95da8 --- /dev/null +++ b/app/src/forms/common/models/tables/documentTemplate.js @@ -0,0 +1,48 @@ +const { Model } = require('objection'); +const { Timestamps } = require('../mixins'); +const { Regex } = require('../../constants'); +const stamps = require('../jsonSchema').stamps; + +class DocumentTemplate extends Timestamps(Model) { + static get tableName() { + return 'document_template'; + } + + static get modifiers() { + return { + filterActive(query, value) { + if (value) { + query.where('active', value); + } + }, + filterFormId(query, value) { + if (value) { + query.where('formId', value); + } + }, + filterId(query, value) { + if (value) { + query.where('id', value); + } + }, + }; + } + + static get jsonSchema() { + return { + type: 'object', + required: ['filename', 'formId', 'template'], + properties: { + id: { type: 'string', pattern: Regex.UUID }, + formId: { type: 'string', pattern: Regex.UUID }, + active: { type: 'boolean' }, + filename: { type: 'string' }, + template: { type: 'string' }, + ...stamps, + }, + additionalProperties: false, + }; + } +} + +module.exports = DocumentTemplate; diff --git a/app/src/forms/common/models/tables/form.js b/app/src/forms/common/models/tables/form.js index 404fa11bc..15f989c9b 100644 --- a/app/src/forms/common/models/tables/form.js +++ b/app/src/forms/common/models/tables/form.js @@ -119,6 +119,7 @@ class Form extends Timestamps(Model) { 'active', 'allowSubmitterToUploadFile', 'showSubmissionConfirmation', + 'enableDocumentTemplates', 'enableStatusUpdates', 'schedule', 'subscribe', @@ -149,6 +150,7 @@ class Form extends Timestamps(Model) { sendSubmissionReceivedEmail: { type: 'boolean' }, showSubmissionConfirmation: { type: 'boolean' }, submissionReceivedEmails: { type: ['array', 'null'], items: { type: 'string', pattern: Regex.EMAIL } }, + enableDocumentTemplates: { type: 'boolean' }, enableStatusUpdates: { type: 'boolean' }, enableSubmitterDraft: { type: 'boolean' }, schedule: { type: 'object' }, diff --git a/app/src/forms/form/controller.js b/app/src/forms/form/controller.js index 77515823d..730627bcc 100644 --- a/app/src/forms/form/controller.js +++ b/app/src/forms/form/controller.js @@ -6,6 +6,71 @@ const service = require('./service'); const fileService = require('../file/service'); module.exports = { + /** + * Creates a document template that can be used to generate a document from + * a form's submission data. + * + * @param {Object} req the Express object representing the HTTP request + * @param {Object} res the Express object representing the HTTP response + * @param {Object} next the Express chaining function + */ + documentTemplateCreate: async (req, res, next) => { + try { + const response = await service.documentTemplateCreate(req.params.formId, req.body, req.currentUser.usernameIdp); + res.status(201).json(response); + } catch (error) { + next(error); + } + }, + + /** + * Deletes an active document template given its ID. + * + * @param {Object} req the Express object representing the HTTP request + * @param {Object} res the Express object representing the HTTP response + * @param {Object} next the Express chaining function + */ + documentTemplateDelete: async (req, res, next) => { + try { + await service.documentTemplateDelete(req.params.documentTemplateId, req.currentUser.usernameIdp); + res.status(204).send(); + } catch (error) { + next(error); + } + }, + + /** + * Gets the active document templates for a form. + * + * @param {Object} req the Express object representing the HTTP request + * @param {Object} res the Express object representing the HTTP response + * @param {Object} next the Express chaining function + */ + documentTemplateList: async (req, res, next) => { + try { + const response = await service.documentTemplateList(req.params.formId); + res.status(200).json(response); + } catch (error) { + next(error); + } + }, + + /** + * Reads an active document template given its ID. + * + * @param {Object} req the Express object representing the HTTP request + * @param {Object} res the Express object representing the HTTP response + * @param {Object} next the Express chaining function + */ + documentTemplateRead: async (req, res, next) => { + try { + const response = await service.documentTemplateRead(req.params.documentTemplateId); + res.status(200).json(response); + } catch (error) { + next(error); + } + }, + export: async (req, res, next) => { try { const result = await exportService.export(req.params.formId, req.query, req.currentUser, req.headers.referer); diff --git a/app/src/forms/form/routes.js b/app/src/forms/form/routes.js index 4da2cbb56..d7899d560 100644 --- a/app/src/forms/form/routes.js +++ b/app/src/forms/form/routes.js @@ -11,6 +11,7 @@ const controller = require('./controller'); routes.use(currentUser); +routes.param('documentTemplateId', validateParameter.validateDocumentTemplateId); routes.param('formId', validateParameter.validateFormId); routes.param('formVersionDraftId', validateParameter.validateFormVersionDraftId); routes.param('formVersionId', validateParameter.validateFormVersionId); @@ -27,6 +28,22 @@ routes.get('/:formId', rateLimiter, apiAccess, hasFormPermissions([P.FORM_READ]) await controller.readForm(req, res, next); }); +routes.get('/:formId/documentTemplates', rateLimiter, apiAccess, hasFormPermissions([P.DOCUMENT_TEMPLATE_READ]), async (req, res, next) => { + await controller.documentTemplateList(req, res, next); +}); + +routes.post('/:formId/documentTemplates', rateLimiter, apiAccess, hasFormPermissions([P.DOCUMENT_TEMPLATE_CREATE]), async (req, res, next) => { + await controller.documentTemplateCreate(req, res, next); +}); + +routes.delete('/:formId/documentTemplates/:documentTemplateId', rateLimiter, apiAccess, hasFormPermissions([P.DOCUMENT_TEMPLATE_DELETE]), async (req, res, next) => { + await controller.documentTemplateDelete(req, res, next); +}); + +routes.get('/:formId/documentTemplates/:documentTemplateId', rateLimiter, apiAccess, hasFormPermissions([P.DOCUMENT_TEMPLATE_READ]), async (req, res, next) => { + await controller.documentTemplateRead(req, res, next); +}); + routes.get('/:formId/export', rateLimiter, apiAccess, hasFormPermissions([P.FORM_READ, P.SUBMISSION_READ]), async (req, res, next) => { await controller.export(req, res, next); }); diff --git a/app/src/forms/form/service.js b/app/src/forms/form/service.js index 64b3211aa..e1c1bbeb7 100644 --- a/app/src/forms/form/service.js +++ b/app/src/forms/form/service.js @@ -5,6 +5,7 @@ const { EmailTypes } = require('../common/constants'); const eventService = require('../event/eventService'); const moment = require('moment'); const { + DocumentTemplate, FileStorage, Form, FormApiKey, @@ -262,6 +263,89 @@ const service = { }); }, + /** + * Creates a document template that can be used to generate a document from + * a form's submission data. + * + * @param {uuid} formId the identifier for the form. + * @param {object} data the data for the document template. + * @param {string} currentUsername the currently logged in user's username. + * @returns the created object. + */ + documentTemplateCreate: async (formId, data, currentUsername) => { + let trx; + + try { + const documentTemplate = { + id: uuidv4(), + formId: formId, + filename: data.filename, + template: data.template, + createdBy: currentUsername, + }; + + trx = await DocumentTemplate.startTransaction(); + await DocumentTemplate.query(trx).insert(documentTemplate); + await trx.commit(); + + const result = await service.documentTemplateRead(documentTemplate.id); + + return result; + } catch (error) { + if (trx) { + await trx.rollback(); + } + + throw error; + } + }, + + /** + * Deletes an active document template given its ID. + * + * @param {uuid} documentTemplateId the id of the document template. + * @param {string} currentUsername the currently logged in user's username. + * @throws an Error if the document template does not exist. + */ + documentTemplateDelete: async (documentTemplateId, currentUsername) => { + let trx; + try { + trx = await DocumentTemplate.startTransaction(); + await DocumentTemplate.query(trx).patchAndFetchById(documentTemplateId, { + active: false, + updatedBy: currentUsername, + }); + await trx.commit(); + } catch (error) { + if (trx) { + await trx.rollback(); + } + + throw error; + } + }, + + /** + * Gets the active document templates for a form. + * + * @param {uuid} formId the identifier for the form. + * @returns a Promise for the document templates belonging to a form. + */ + documentTemplateList: (formId) => { + return DocumentTemplate.query().modify('filterFormId', formId).modify('filterActive', true); + }, + + /** + * Reads an active document template given its ID. + * + * @param {uuid} documentTemplateId the id of the document template. + * @returns a Promise for the document template. + * @throws an Error if the document template does not exist. + */ + documentTemplateRead: (documentTemplateId) => { + return DocumentTemplate.query().findById(documentTemplateId).modify('filterActive', true).throwIfNotFound(); + }, + listFormSubmissions: async (formId, params) => { const query = SubmissionMetadata.query() .where('formId', formId) diff --git a/app/tests/common/dbHelper.js b/app/tests/common/dbHelper.js index 208a1eae0..02049f8ee 100644 --- a/app/tests/common/dbHelper.js +++ b/app/tests/common/dbHelper.js @@ -41,7 +41,6 @@ MockModel.insert = jest.fn().mockReturnThis(); MockModel.insertAndFetch = jest.fn().mockReturnThis(); MockModel.insertGraph = jest.fn().mockReturnThis(); MockModel.insertGraphAndFetch = jest.fn().mockReturnThis(); -MockModel.findById = jest.fn().mockReturnThis(); MockModel.modify = jest.fn().mockReturnThis(); MockModel.modifiers = jest.fn().mockReturnThis(); MockModel.orderBy = jest.fn().mockReturnThis(); diff --git a/app/tests/unit/forms/common/middleware/validateParameter.spec.js b/app/tests/unit/forms/common/middleware/validateParameter.spec.js index adc94ef6e..1af2500c7 100644 --- a/app/tests/unit/forms/common/middleware/validateParameter.spec.js +++ b/app/tests/unit/forms/common/middleware/validateParameter.spec.js @@ -13,6 +13,106 @@ afterEach(() => { jest.clearAllMocks(); }); +describe('validateDocumentTemplateId', () => { + const documentTemplateId = uuidv4(); + + const mockReadDocumentTemplateResponse = { + formId: formId, + id: documentTemplateId, + }; + + formService.documentTemplateRead = jest.fn().mockReturnValue(mockReadDocumentTemplateResponse); + + describe('400 response when', () => { + const expectedStatus = { status: 400 }; + + test('documentTemplateId is missing', async () => { + const req = getMockReq({ + params: { + formId: formId, + }, + }); + const { res, next } = getMockRes(); + + await validateParameter.validateDocumentTemplateId(req, res, next); + + expect(formService.documentTemplateRead).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.objectContaining(expectedStatus)); + }); + + test.each(invalidUuids)('documentTemplateId is "%s"', async (eachDocumentTemplateId) => { + const req = getMockReq({ + params: { formId: formId, documentTemplateId: eachDocumentTemplateId }, + }); + const { res, next } = getMockRes(); + + await validateParameter.validateDocumentTemplateId(req, res, next, eachDocumentTemplateId); + + expect(formService.documentTemplateRead).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.objectContaining(expectedStatus)); + }); + }); + + describe('404 response when', () => { + const expectedStatus = { status: 404 }; + + test('formId does not match', async () => { + formService.documentTemplateRead.mockReturnValueOnce({ + formId: uuidv4(), + id: documentTemplateId, + }); + const req = getMockReq({ + params: { + formId: formId, + documentTemplateId: documentTemplateId, + }, + }); + const { res, next } = getMockRes(); + + await validateParameter.validateDocumentTemplateId(req, res, next, documentTemplateId); + + expect(formService.documentTemplateRead).toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.objectContaining(expectedStatus)); + }); + }); + + describe('handles error thrown by', () => { + test('documentTemplateRead', async () => { + const error = new Error(); + formService.documentTemplateRead.mockRejectedValueOnce(error); + const req = getMockReq({ + params: { + formId: formId, + documentTemplateId: documentTemplateId, + }, + }); + const { res, next } = getMockRes(); + + await validateParameter.validateDocumentTemplateId(req, res, next, documentTemplateId); + + expect(formService.documentTemplateRead).toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(error); + }); + }); + + describe('allows', () => { + test('document template with matching form id', async () => { + const req = getMockReq({ + params: { + formId: formId, + documentTemplateId: documentTemplateId, + }, + }); + const { res, next } = getMockRes(); + + await validateParameter.validateDocumentTemplateId(req, res, next, documentTemplateId); + + expect(formService.documentTemplateRead).toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(); + }); + }); +}); + describe('validateFormId', () => { describe('400 response when', () => { const expectedStatus = { status: 400 }; diff --git a/app/tests/unit/forms/form/controller.spec.js b/app/tests/unit/forms/form/controller.spec.js index 61ce5926b..606b80311 100644 --- a/app/tests/unit/forms/form/controller.spec.js +++ b/app/tests/unit/forms/form/controller.spec.js @@ -5,9 +5,31 @@ const controller = require('../../../../src/forms/form/controller'); const exportService = require('../../../../src/forms/form/exportService'); const service = require('../../../../src/forms/form/service'); +const currentUser = { + usernameIdp: 'TESTER', +}; + +const documentTemplate = { + filename: 'cdogs_template.txt', + formId: uuidv4(), + id: uuidv4(), + template: 'My Template', +}; + +const error = new Error('error'); + // Various strings that should produce 400 errors when used as UUIDs. const testCases400 = [[''], ['undefined'], ['{{oops}}'], [uuidv4() + '.']]; +// +// Mock out all happy-path service calls. +// + +service.documentTemplateCreate = jest.fn().mockReturnValue(documentTemplate); +service.documentTemplateDelete = jest.fn().mockReturnValue(); +service.documentTemplateList = jest.fn().mockReturnValue([]); +service.documentTemplateRead = jest.fn().mockReturnValue(documentTemplate); + const req = { params: { formId: 'bd4dcf26-65bd-429b-967f-125500bfd8a4' }, query: { @@ -23,6 +45,189 @@ const req = { }, }; +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('documentTemplateCreate', () => { + const validRequest = { + body: { + ...documentTemplate, + }, + currentUser: currentUser, + params: { + formId: documentTemplate.formId, + }, + }; + + describe('error response when', () => { + it('has no current user', async () => { + const invalidRequest = { ...validRequest }; + delete invalidRequest.currentUser; + const req = getMockReq(invalidRequest); + const { res, next } = getMockRes(); + + await controller.documentTemplateCreate(req, res, next); + + expect(res.json).not.toBeCalled(); + expect(res.status).not.toBeCalled(); + expect(next).toBeCalledWith(expect.any(TypeError)); + }); + + it('has an unsuccessful service call', async () => { + service.documentTemplateCreate.mockRejectedValueOnce(error); + const req = getMockReq(validRequest); + const { res, next } = getMockRes(); + + await controller.documentTemplateCreate(req, res, next); + + expect(service.documentTemplateCreate).toBeCalledWith(validRequest.params.formId, validRequest.body, validRequest.currentUser.usernameIdp); + expect(res.json).not.toBeCalled(); + expect(res.status).not.toBeCalled(); + expect(next).toBeCalledWith(error); + }); + }); + + describe('201 response when', () => { + it('has a successful service call', async () => { + const req = getMockReq(validRequest); + const { res, next } = getMockRes(); + + await controller.documentTemplateCreate(req, res, next); + + expect(service.documentTemplateCreate).toBeCalledWith(validRequest.params.formId, validRequest.body, validRequest.currentUser.usernameIdp); + expect(res.json).toBeCalledWith( + expect.objectContaining({ + ...documentTemplate, + }) + ); + expect(res.status).toBeCalledWith(201); + expect(next).not.toBeCalled(); + }); + }); +}); + +describe('documentTemplateDelete', () => { + const validRequest = { + currentUser: currentUser, + params: { + documentTemplateId: documentTemplate.id, + }, + }; + + describe('error response when', () => { + it('has no current user', async () => { + const invalidRequest = { ...validRequest }; + delete invalidRequest.currentUser; + const req = getMockReq(invalidRequest); + const { res, next } = getMockRes(); + + await controller.documentTemplateDelete(req, res, next); + + expect(res.json).not.toBeCalled(); + expect(res.status).not.toBeCalled(); + expect(next).toBeCalledWith(expect.any(TypeError)); + }); + + it('has an unsuccessful service call', async () => { + service.documentTemplateDelete.mockRejectedValueOnce(error); + const req = getMockReq(validRequest); + const { res, next } = getMockRes(); + + await controller.documentTemplateDelete(req, res, next); + + expect(service.documentTemplateDelete).toBeCalledWith(validRequest.params.documentTemplateId, validRequest.currentUser.usernameIdp); + expect(res.json).not.toBeCalled(); + expect(res.status).not.toBeCalled(); + expect(next).toBeCalledWith(error); + }); + }); + + describe('204 response when', () => { + it('has a successful service call', async () => { + const req = getMockReq(validRequest); + const { res, next } = getMockRes(); + + await controller.documentTemplateDelete(req, res, next); + + expect(service.documentTemplateDelete).toBeCalledWith(validRequest.params.documentTemplateId, validRequest.currentUser.usernameIdp); + expect(res.json).not.toBeCalled(); + expect(res.status).toBeCalledWith(204); + expect(next).not.toBeCalled(); + }); + }); +}); + +describe('documentTemplateList', () => { + const validRequest = { + params: { formId: documentTemplate.formId }, + }; + + describe('error response when', () => { + it('has an unsuccessful service call', async () => { + service.documentTemplateList.mockRejectedValueOnce(error); + const req = getMockReq(validRequest); + const { res, next } = getMockRes(); + + await controller.documentTemplateList(req, res, next); + + expect(service.documentTemplateList).toBeCalledWith(validRequest.params.formId); + expect(res.json).not.toBeCalled(); + expect(res.status).not.toBeCalled(); + expect(next).toBeCalledWith(error); + }); + }); + + describe('200 response when', () => { + it('has a successful service call', async () => { + const req = getMockReq(validRequest); + const { res, next } = getMockRes(); + + await controller.documentTemplateList(req, res, next); + + expect(service.documentTemplateList).toBeCalledWith(validRequest.params.formId); + expect(res.json).toBeCalledWith([]); + expect(res.status).toBeCalledWith(200); + expect(next).not.toBeCalled(); + }); + }); +}); + +describe('documentTemplateRead', () => { + const validRequest = { + params: { formId: documentTemplate.formId }, + }; + + describe('error response when', () => { + it('has an unsuccessful service call', async () => { + service.documentTemplateRead.mockRejectedValueOnce(error); + const req = getMockReq(validRequest); + const { res, next } = getMockRes(); + + await controller.documentTemplateRead(req, res, next); + + expect(service.documentTemplateRead).toBeCalledWith(validRequest.params.documentTemplateId); + expect(res.json).not.toBeCalled(); + expect(res.status).not.toBeCalled(); + expect(next).toBeCalledWith(error); + }); + }); + + describe('200 response when', () => { + it('has a successful service call', async () => { + const req = getMockReq(validRequest); + const { res, next } = getMockRes(); + + await controller.documentTemplateRead(req, res, next); + + expect(service.documentTemplateRead).toBeCalledWith(validRequest.params.documentTemplateId); + expect(res.json).toBeCalledWith(documentTemplate); + expect(res.status).toBeCalledWith(200); + expect(next).not.toBeCalled(); + }); + }); +}); + describe('form controller', () => { it('should get all form and submissions fields for CSV export', async () => { const formFields = [ diff --git a/app/tests/unit/forms/form/routes.spec.js b/app/tests/unit/forms/form/routes.spec.js new file mode 100644 index 000000000..c9a72b805 --- /dev/null +++ b/app/tests/unit/forms/form/routes.spec.js @@ -0,0 +1,120 @@ +const request = require('supertest'); +const { v4: uuidv4 } = require('uuid'); + +const { expressHelper } = require('../../../common/helper'); + +const apiAccess = require('../../../../src/forms/auth/middleware/apiAccess'); +const userAccess = require('../../../../src/forms/auth/middleware/userAccess'); +const rateLimiter = require('../../../../src/forms/common/middleware/rateLimiter'); +const validateParameter = require('../../../../src/forms/common/middleware/validateParameter'); +const controller = require('../../../../src/forms/form/controller'); + +// +// Mock out all the middleware - we're testing that the routes are set up +// correctly, not the functionality of the middleware. +// + +jest.mock('../../../../src/forms/auth/middleware/apiAccess'); +apiAccess.mockImplementation( + jest.fn((_req, _res, next) => { + next(); + }) +); + +controller.documentTemplateCreate = jest.fn((_req, _res, next) => { + next(); +}); +controller.documentTemplateDelete = jest.fn((_req, _res, next) => { + next(); +}); +controller.documentTemplateList = jest.fn((_req, _res, next) => { + next(); +}); +controller.documentTemplateRead = jest.fn((_req, _res, next) => { + next(); +}); + +rateLimiter.apiKeyRateLimiter = jest.fn((_req, _res, next) => { + next(); +}); + +const hasFormPermissionsMock = jest.fn((_req, _res, next) => { + next(); +}); + +userAccess.hasFormPermissions = jest.fn(() => { + return hasFormPermissionsMock; +}); + +validateParameter.validateDocumentTemplateId = jest.fn((_req, _res, next) => { + next(); +}); +validateParameter.validateFormId = jest.fn((_req, _res, next) => { + next(); +}); + +const documentTemplateId = uuidv4(); +const formId = uuidv4(); + +// +// Create the router and a simple Express server. +// + +const router = require('../../../../src/forms/form/routes'); +const basePath = '/form'; +const app = expressHelper(basePath, router); +const appRequest = request(app); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe(`${basePath}/:formId/documentTemplates`, () => { + const path = `${basePath}/${formId}/documentTemplates`; + + it('should have correct middleware for GET', async () => { + await appRequest.get(path); + + expect(validateParameter.validateFormId).toHaveBeenCalledTimes(1); + expect(apiAccess).toHaveBeenCalledTimes(1); + expect(rateLimiter.apiKeyRateLimiter).toHaveBeenCalledTimes(1); + expect(hasFormPermissionsMock).toHaveBeenCalledTimes(1); + expect(controller.documentTemplateList).toHaveBeenCalledTimes(1); + }); + + it('should have correct middleware for POST', async () => { + await appRequest.post(path); + + expect(validateParameter.validateFormId).toHaveBeenCalledTimes(1); + expect(apiAccess).toHaveBeenCalledTimes(1); + expect(rateLimiter.apiKeyRateLimiter).toHaveBeenCalledTimes(1); + expect(hasFormPermissionsMock).toHaveBeenCalledTimes(1); + expect(controller.documentTemplateCreate).toHaveBeenCalledTimes(1); + }); +}); + +describe(`${basePath}/:formId/documentTemplates/:documentTemplateId`, () => { + const path = `${basePath}/${formId}/documentTemplates/${documentTemplateId}`; + + it('should have correct middleware for DELETE', async () => { + await appRequest.delete(path); + + expect(validateParameter.validateDocumentTemplateId).toHaveBeenCalledTimes(1); + expect(validateParameter.validateFormId).toHaveBeenCalledTimes(1); + expect(apiAccess).toHaveBeenCalledTimes(1); + expect(rateLimiter.apiKeyRateLimiter).toHaveBeenCalledTimes(1); + expect(hasFormPermissionsMock).toHaveBeenCalledTimes(1); + expect(controller.documentTemplateDelete).toHaveBeenCalledTimes(1); + }); + + it('should have correct middleware for GET', async () => { + await appRequest.get(path); + + expect(validateParameter.validateDocumentTemplateId).toHaveBeenCalledTimes(1); + expect(validateParameter.validateFormId).toHaveBeenCalledTimes(1); + expect(apiAccess).toHaveBeenCalledTimes(1); + expect(rateLimiter.apiKeyRateLimiter).toHaveBeenCalledTimes(1); + expect(hasFormPermissionsMock).toHaveBeenCalledTimes(1); + expect(controller.documentTemplateRead).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/tests/unit/forms/form/service.spec.js b/app/tests/unit/forms/form/service.spec.js index d7eee486d..6c172a0ad 100644 --- a/app/tests/unit/forms/form/service.spec.js +++ b/app/tests/unit/forms/form/service.spec.js @@ -5,19 +5,35 @@ const { v4: uuidv4 } = require('uuid'); const { EmailTypes } = require('../../../../src/forms/common/constants'); const service = require('../../../../src/forms/form/service'); +jest.mock('../../../../src/forms/common/models/tables/documentTemplate', () => MockModel); jest.mock('../../../../src/forms/common/models/tables/formEmailTemplate', () => MockModel); jest.mock('../../../../src/forms/common/models/views/submissionMetadata', () => MockModel); +const documentTemplateId = uuidv4(); +const formId = uuidv4(); + +const currentUser = { + usernameIdp: 'TESTER', +}; + +const documentTemplate = { + filename: 'cdogs_template.txt', + formId: formId, + id: documentTemplateId, + template: 'My Template', +}; + const emailTemplateSubmissionConfirmation = { body: 'default submission confirmation body', - formId: uuidv4(), + formId: formId, subject: 'default submission confirmation subject', title: 'default submission confirmation title', type: EmailTypes.SUBMISSION_CONFIRMATION, }; + const emailTemplate = { body: 'body', - formId: uuidv4(), + formId: formId, subject: 'subject', title: 'title', type: EmailTypes.SUBMISSION_CONFIRMATION, @@ -32,6 +48,157 @@ afterEach(() => { jest.restoreAllMocks(); }); +describe('Document Templates', () => { + describe('documentTemplateCreate', () => { + // Need to temporarily replace calls to other functions within the module - + // they will be tested elsewhere. + beforeEach(() => { + jest.spyOn(service, 'documentTemplateRead').mockImplementation(() => documentTemplate); + }); + + it('should not roll back transaction create problems', async () => { + const error = new Error('error'); + MockModel.startTransaction.mockImplementationOnce(() => { + throw error; + }); + + await expect(service.documentTemplateCreate(formId, documentTemplate, currentUser)).rejects.toThrow(error); + + expect(MockTransaction.commit).toBeCalledTimes(0); + expect(MockTransaction.rollback).toBeCalledTimes(0); + }); + + it('should propagate database errors', async () => { + const error = new Error('error'); + MockModel.insert.mockImplementationOnce(() => { + throw error; + }); + + await expect(service.documentTemplateCreate(formId, documentTemplate, currentUser)).rejects.toThrow(error); + + expect(MockTransaction.commit).toBeCalledTimes(0); + expect(MockTransaction.rollback).toBeCalledTimes(1); + }); + + it('should update database', async () => { + MockModel.mockResolvedValue(documentTemplate); + const newDocumentTemplate = { ...documentTemplate }; + delete newDocumentTemplate.id; + + await service.documentTemplateCreate(formId, newDocumentTemplate, currentUser.usernameIdp); + + expect(MockModel.query).toBeCalledTimes(1); + expect(MockModel.query).toBeCalledWith(MockTransaction); + expect(MockModel.insert).toBeCalledTimes(1); + expect(MockModel.insert).toBeCalledWith( + expect.objectContaining({ + ...newDocumentTemplate, + createdBy: currentUser.usernameIdp, + }) + ); + expect(MockTransaction.commit).toBeCalledTimes(1); + expect(MockTransaction.rollback).toBeCalledTimes(0); + }); + }); + + describe('documentTemplateDelete', () => { + it('should not roll back transaction create problems', async () => { + const error = new Error('error'); + MockModel.startTransaction.mockImplementationOnce(() => { + throw error; + }); + + await expect(service.documentTemplateDelete(formId, documentTemplate, currentUser)).rejects.toThrow(error); + + expect(MockTransaction.commit).toBeCalledTimes(0); + expect(MockTransaction.rollback).toBeCalledTimes(0); + }); + + it('should propagate database errors', async () => { + const error = new Error('error'); + MockModel.patchAndFetchById.mockImplementationOnce(() => { + throw error; + }); + + await expect(service.documentTemplateDelete(documentTemplateId, currentUser.usernameIdp)).rejects.toThrow(error); + + expect(MockTransaction.commit).toBeCalledTimes(0); + expect(MockTransaction.rollback).toBeCalledTimes(1); + }); + + it('should update database', async () => { + MockModel.mockResolvedValue(documentTemplate); + + await service.documentTemplateDelete(documentTemplateId, currentUser.usernameIdp); + + expect(MockModel.query).toBeCalledTimes(1); + expect(MockModel.query).toBeCalledWith(MockTransaction); + expect(MockModel.patchAndFetchById).toBeCalledTimes(1); + expect(MockModel.patchAndFetchById).toBeCalledWith( + documentTemplateId, + expect.objectContaining({ + active: false, + updatedBy: currentUser.usernameIdp, + }) + ); + expect(MockTransaction.commit).toBeCalledTimes(1); + expect(MockTransaction.rollback).toBeCalledTimes(0); + }); + }); + + describe('documentTemplateList', () => { + it('should propagate database errors', async () => { + const error = new Error('error'); + MockModel.query.mockImplementationOnce(() => { + throw error; + }); + + expect(service.documentTemplateList).toThrow(error); + }); + + it('should query database', async () => { + MockModel.mockResolvedValue([documentTemplate]); + + const result = await service.documentTemplateList(formId); + + expect(result).toEqual([documentTemplate]); + + expect(MockModel.query).toBeCalledTimes(1); + expect(MockModel.query).toBeCalledWith(); + expect(MockModel.modify).toBeCalledTimes(2); + expect(MockModel.modify).toBeCalledWith('filterActive', true); + expect(MockModel.modify).toBeCalledWith('filterFormId', formId); + }); + }); + + describe('documentTemplateRead', () => { + it('should propagate database errors', async () => { + const error = new Error('error'); + MockModel.query.mockImplementationOnce(() => { + throw error; + }); + + expect(service.documentTemplateRead).toThrow(error); + }); + + it('should query database', async () => { + MockModel.mockResolvedValue(documentTemplate); + + const result = await service.documentTemplateRead(documentTemplateId); + + expect(result).toEqual(documentTemplate); + + expect(MockModel.query).toBeCalledTimes(1); + expect(MockModel.query).toBeCalledWith(); + expect(MockModel.findById).toBeCalledTimes(1); + expect(MockModel.findById).toBeCalledWith(documentTemplateId); + expect(MockModel.modify).toBeCalledTimes(1); + expect(MockModel.modify).toBeCalledWith('filterActive', true); + expect(MockModel.throwIfNotFound).toBeCalledTimes(1); + }); + }); +}); + describe('_findFileIds', () => { it('should handle a blank everything', () => { const schema = { @@ -400,14 +567,8 @@ describe('readVersionFields', () => { describe('processPaginationData', () => { const SubmissionData = require('../../../fixtures/submission/kitchen_sink_submission_pagination.json'); - // Put the MockModel.query back to what it was, so that the tests that follow - // can run. - afterAll(() => { - MockModel.query = jest.fn().mockReturnThis(); - }); - it('fetch first-page data with 10 items per page', async () => { - MockModel.query.mockImplementation((data) => { + MockModel.query.mockImplementationOnce((data) => { return { page: function (page, itemsPerPage) { let start = page * itemsPerPage; @@ -422,7 +583,7 @@ describe('processPaginationData', () => { expect(result.total).toEqual(SubmissionData.length); }); it('fetch first-page data with 5 items per page', async () => { - MockModel.query.mockImplementation((data) => { + MockModel.query.mockImplementationOnce((data) => { return { page: function (page, itemsPerPage) { let start = page * itemsPerPage; @@ -437,7 +598,7 @@ describe('processPaginationData', () => { expect(result.total).toEqual(SubmissionData.length); }); it('fetch second-page data with 5 items per page', async () => { - MockModel.query.mockImplementation((data) => { + MockModel.query.mockImplementationOnce((data) => { return { page: function (page, itemsPerPage) { let start = page * itemsPerPage; @@ -452,23 +613,23 @@ describe('processPaginationData', () => { expect(result.total).toEqual(SubmissionData.length); }); it('search submission data with pagination base on datetime', async () => { - MockModel.query.mockImplementation((data) => data); + MockModel.query.mockImplementationOnce((data) => data); let result = await service.processPaginationData(MockModel.query(SubmissionData), 0, 5, 0, '2023-08-19T19:11', true); expect(result.results).toHaveLength(3); expect(result.total).toEqual(3); }); it('search submission data with pagination base on any value (first page)', async () => { - MockModel.query.mockImplementation((data) => data); + MockModel.query.mockImplementationOnce((data) => data); let result = await service.processPaginationData(MockModel.query(SubmissionData), 0, 5, 0, 'a', true); expect(result.results).toHaveLength(5); }); it('search submission data with pagination base on any value (second page)', async () => { - MockModel.query.mockImplementation((data) => data); + MockModel.query.mockImplementationOnce((data) => data); let result = await service.processPaginationData(MockModel.query(SubmissionData), 1, 5, 0, 'a', true); expect(result.results).toHaveLength(5); }); it('search submission data with pagination base on any value (test for case)', async () => { - MockModel.query.mockImplementation((data) => data); + MockModel.query.mockImplementationOnce((data) => data); let result = await service.processPaginationData(MockModel.query(SubmissionData), 0, 10, 0, 'A', true); expect(result.results).toHaveLength(10); });