From 0f517158ee76e5044c6e3427d374e3a4e6290d98 Mon Sep 17 00:00:00 2001 From: Walter Moar Date: Wed, 17 Apr 2024 16:58:19 -0700 Subject: [PATCH] feat: FORMS-1042 new template rendering route (#1323) * feat/FORMS-1042 new route for document generation * added docs --- app/src/docs/v1.api-spec.yaml | 116 ++++-- .../common/middleware/validateParameter.js | 19 +- app/src/forms/submission/controller.js | 76 +++- app/src/forms/submission/routes.js | 12 +- .../middleware/validateParameter.spec.js | 180 +++++++-- .../unit/forms/submission/controller.spec.js | 381 +++++++++++++----- .../unit/forms/submission/routes.spec.js | 72 ++++ app/tests/unit/routes/v1/submission.spec.js | 3 + 8 files changed, 702 insertions(+), 157 deletions(-) create mode 100644 app/tests/unit/forms/submission/routes.spec.js diff --git a/app/src/docs/v1.api-spec.yaml b/app/src/docs/v1.api-spec.yaml index 52ff38a76..915d609a4 100755 --- a/app/src/docs/v1.api-spec.yaml +++ b/app/src/docs/v1.api-spec.yaml @@ -45,7 +45,7 @@ tags: This section of the API includes endpoints used to perform various operations related to form drafts, for example create or publish a draft from a specific version of a form. - - name: Document Template + - name: Document Templates description: > Documents can be generated using the Common Document Generation Service ([CDOGS](https://bcgov.github.io/common-service-showcase/services/cdogs.html)). @@ -57,22 +57,20 @@ tags: CHEFS app itself) until they stabilize. - The MVP workflow is that a form has a single active document template: + Currently only a single document template per form is supported by the + CHEFS application: 1. The `POST /forms/{formId}/documentTemplates` route is used to create a document template for a form. 2. Subsequent calls to the `POST` route should be paired with calls to the `DELETE` route to delete the previously active template. - 3. When it comes time to generate the document, the - `GET /forms/{formId}/documentTemplates` route is used to get "all" - the document template metadata for a form (although there is only one - active document template). This route only returns metadata and not - the (possibly huge) template files themselves. - 4. The `GET /forms/{formId}/documentTemplates/{documentTemplateId}` - route is used to get the specific document template's template file. - - This "double GET" doesn't make a lot of sense when there is only one - document template for a form, but this will change soon when we allow the - user to choose which template they want to use for document generation. + + Submission metadata is available for use in the document templates: + - `{d.chefs.submissionId}`: the unique identifier for the submission, + such as `3cb9acc7-cfd8-4491-b091-1277bc0ec303` + - `{d.chefs.confirmationId}`: The uppercased first eight characters of + the `submissionId`, such as `3CB9ACC7` + - `{d.chefs.formVersion}`: The numeric version of the form that was used + to create the submission, such as `1` - name: Submission description: >- These API endpoints handle the input data provided by a user that @@ -422,7 +420,7 @@ paths: - BearerAuth: [] OpenID: [] tags: - - Document Template + - Document Templates parameters: - $ref: '#/components/parameters/formIdParam' responses: @@ -454,7 +452,7 @@ paths: schema: $ref: '#/components/schemas/Error' post: - summary: Create a document template for a form + summary: Create a document template description: > Creates a document template for a form. This makes it easier for users to generate documents, as the template is associated with the form and @@ -465,7 +463,7 @@ paths: - BearerAuth: [] OpenID: [] tags: - - Document Template + - Document Templates parameters: - $ref: '#/components/parameters/formIdParam' requestBody: @@ -502,7 +500,7 @@ paths: $ref: '#/components/schemas/Error' /forms/{formId}/documentTemplates/{documentTemplateId}: get: - summary: Get a document template for a form + summary: Get a document template description: > Gets a document template for the given form. The response will include the `template` field, which is the actual document template file. @@ -512,7 +510,7 @@ paths: - BearerAuth: [] OpenID: [] tags: - - Document Template + - Document Templates parameters: - $ref: '#/components/parameters/formIdParam' - $ref: '#/components/parameters/documentTemplateIdParam' @@ -554,7 +552,7 @@ paths: - BearerAuth: [] OpenID: [] tags: - - Document Template + - Document Templates parameters: - $ref: '#/components/parameters/formIdParam' - $ref: '#/components/parameters/documentTemplateIdParam' @@ -580,7 +578,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /forms/{formId}/export: get: summary: Export submissions for a form @@ -1724,9 +1721,78 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /submissions/{formSubmissionId}/template/{documentTemplateId}/render: + get: + summary: Generate document from submission ID and template ID + description: >- + Uses form submission data (given the form submission ID) and a document + template (given the document template ID) and generates a document + containing the submission data. + + #### Common Document Generation Service (CDOGS) + + This endpoint is a passthrough to CDOGS. Instead of using *BasicAuth* to + call this endpoint, it is highly recommended that you [directly call + CDOGS](https://bcgov.github.io/common-service-showcase/services/cdogs.html). + Benefits of calling CDOGS directly from your application: + - avoid CHEFS API rate limiting restrictions + - avoid CHEFS API timeout restrictions (for large documents) + - better performance (direct call instead of passthrough) + - ability to use the CDOGS template cache + - direct support from the CDOGS team for your specific Client ID + operationId: templateIdSubmissionIdRender + security: + - BasicAuth: [] + - BearerAuth: [] + OpenID: [] + tags: + - Document Templates + parameters: + - $ref: '#/components/parameters/formSubmissionIdParam' + - $ref: '#/components/parameters/documentTemplateIdParam' + responses: + '200': + description: Returns the document template with merged variables + content: + application/octet-stream: + schema: + type: string + format: binary + description: Raw binary-encoded response + headers: + Content-Disposition: + schema: + type: string + description: >- + Indicates if a browser should render this resource inline or + treat as an attachment for download + example: attachment; filename=file.pdf + Content-Type: + schema: + type: string + description: The MIME-type of the binary file payload + example: application/pdf + RateLimit: + $ref: '#/components/headers/RateLimit' + RateLimit-Policy: + $ref: '#/components/headers/RateLimit-Policy' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '429': + $ref: '#/components/responses/TooManyRequests' + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /submissions/{formSubmissionId}/template/render: post: - summary: Generate a document by providing a document template + summary: Generate document from submission ID and template object description: >- Merges data from a form submission into a document template. @@ -1741,13 +1807,13 @@ paths: - better performance (direct call instead of passthrough) - ability to use the CDOGS template cache - direct support from the CDOGS team for your specific Client ID - operationId: uploadTemplateAndRenderReport + operationId: templateObjectSubmissionIdRender security: - BasicAuth: [] - BearerAuth: [] OpenID: [] tags: - - Document Template + - Document Templates parameters: - $ref: '#/components/parameters/formSubmissionIdParam' requestBody: @@ -3464,7 +3530,7 @@ components: description: > The file that is the document template to be used in document generation. - example: Hello {firstName} + example: Hello {d.firstName} DocumentTemplateResponse: allOf: - $ref: '#/components/schemas/DocumentTemplateResponseBasic' @@ -3475,7 +3541,7 @@ components: description: > The file that is the document template to be used in document generation. - example: Hello {firstName} + example: Hello {d.firstName} DocumentTemplateResponseBasic: allOf: - type: object diff --git a/app/src/forms/common/middleware/validateParameter.js b/app/src/forms/common/middleware/validateParameter.js index fd8b6602f..2554b23c9 100644 --- a/app/src/forms/common/middleware/validateParameter.js +++ b/app/src/forms/common/middleware/validateParameter.js @@ -2,6 +2,7 @@ const Problem = require('api-problem'); const uuid = require('uuid'); const formService = require('../../form/service'); +const submissionService = require('../../submission/service'); /** * Throws a 400 problem if the parameter is not a valid UUID. @@ -20,7 +21,8 @@ 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. + * This validator requires that either the :formId or :formSubmissionId route + * parameter also exists. * * @param {*} req the Express object representing the HTTP request * @param {*} _res the Express object representing the HTTP response - unused @@ -31,8 +33,21 @@ const validateDocumentTemplateId = async (req, _res, next, documentTemplateId) = try { _validateUuid(documentTemplateId, 'documentTemplateId'); + let formId = req.params.formId; + if (!formId) { + const formSubmissionId = req.params.formSubmissionId; + if (!formSubmissionId) { + throw new Problem(404, { + detail: 'documentTemplateId does not exist on this form', + }); + } + + const submission = await submissionService.read(formSubmissionId); + formId = submission.form.id; + } + const documentTemplate = await formService.documentTemplateRead(documentTemplateId); - if (!documentTemplate || documentTemplate.formId !== req.params.formId) { + if (!documentTemplate || documentTemplate.formId !== formId) { throw new Problem(404, { detail: 'documentTemplateId does not exist on this form', }); diff --git a/app/src/forms/submission/controller.js b/app/src/forms/submission/controller.js index 438b2bf38..5ce5895ec 100644 --- a/app/src/forms/submission/controller.js +++ b/app/src/forms/submission/controller.js @@ -1,6 +1,9 @@ -const { Statuses } = require('../common/constants'); const cdogsService = require('../../components/cdogsService'); + +const { Statuses } = require('../common/constants'); const emailService = require('../email/emailService'); +const formService = require('../form/service'); + const service = require('./service'); module.exports = { @@ -119,6 +122,66 @@ module.exports = { next(error); } }, + + /** + * Takes a document template ID and a form submission ID and renders the + * template into a document. + * + * @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. + */ + templateRender: async (req, res, next) => { + try { + const submission = await service.read(req.params.formSubmissionId); + const template = await formService.documentTemplateRead(req.params.documentTemplateId); + const fileName = template.filename.substring(0, template.filename.lastIndexOf('.')); + const fileExtension = template.filename.substring(template.filename.lastIndexOf('.') + 1); + + const templateBody = { + data: { + ...submission.submission.submission.data, + chefs: { + confirmationId: submission.submission.confirmationId, + formVersion: submission.version.version, + submissionId: submission.submission.id, + }, + }, + options: { + convertTo: 'pdf', + overwrite: true, + reportName: fileName, + }, + template: { + content: btoa(template.template), + encodingType: 'base64', + fileType: fileExtension, + }, + }; + + const { data, headers, status } = await cdogsService.templateUploadAndRender(templateBody); + const contentDisposition = headers['content-disposition']; + + res + .status(status) + .set({ + 'Content-Disposition': contentDisposition ? contentDisposition : 'attachment', + 'Content-Type': headers['content-type'], + }) + .send(data); + } catch (error) { + next(error); + } + }, + + /** + * Takes a document template file and a form submission ID and renders the + * template into a document. + * + * @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. + */ templateUploadAndRender: async (req, res, next) => { try { const submission = await service.read(req.params.formSubmissionId); @@ -128,6 +191,7 @@ module.exports = { ...submission.submission.submission.data, chefs: { confirmationId: submission.submission.confirmationId, + formVersion: submission.version.version, submissionId: submission.submission.id, }, }, @@ -146,6 +210,15 @@ module.exports = { next(error); } }, + + /** + * Takes a document template file and a form submission object and renders the + * template into a document. + * + * @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. + */ draftTemplateUploadAndRender: async (req, res, next) => { try { const templateBody = { ...req.body.template, data: req.body.submission.data }; @@ -163,6 +236,7 @@ module.exports = { next(error); } }, + listEdits: async (req, res, next) => { try { const response = await service.listEdits(req.params.formSubmissionId); diff --git a/app/src/forms/submission/routes.js b/app/src/forms/submission/routes.js index 2ab01947b..e2f4696d9 100644 --- a/app/src/forms/submission/routes.js +++ b/app/src/forms/submission/routes.js @@ -5,9 +5,12 @@ const controller = require('./controller'); const P = require('../common/constants').Permissions; const { currentUser, hasSubmissionPermissions, filterMultipleSubmissions } = require('../auth/middleware/userAccess'); const rateLimiter = require('../common/middleware').apiKeyRateLimiter; +const validateParameter = require('../common/middleware/validateParameter'); routes.use(currentUser); +routes.param('documentTemplateId', validateParameter.validateDocumentTemplateId); + routes.get('/:formSubmissionId', rateLimiter, apiAccess, hasSubmissionPermissions(P.SUBMISSION_READ), async (req, res, next) => { await controller.read(req, res, next); }); @@ -56,6 +59,10 @@ routes.get('/:formSubmissionId/edits', hasSubmissionPermissions(P.SUBMISSION_REA await controller.listEdits(req, res, next); }); +routes.get('/:formSubmissionId/template/:documentTemplateId/render', rateLimiter, apiAccess, hasSubmissionPermissions(P.SUBMISSION_READ), async (req, res, next) => { + await controller.templateRender(req, res, next); +}); + routes.post('/:formSubmissionId/template/render', rateLimiter, apiAccess, hasSubmissionPermissions(P.SUBMISSION_READ), async (req, res, next) => { await controller.templateUploadAndRender(req, res, next); }); @@ -64,9 +71,4 @@ routes.delete('/:formSubmissionId/:formId/submissions', hasSubmissionPermissions await controller.deleteMutipleSubmissions(req, res, next); }); -// Implement this when we want to fetch a specific audit row including the whole old submission record -// routes.get('/:formSubmissionId/edits/:auditId', hasSubmissionPermissions(P.SUBMISSION_READ), async (req, res, next) => { -// await controller.listEdits(req, res, next); -// }); - module.exports = routes; diff --git a/app/tests/unit/forms/common/middleware/validateParameter.spec.js b/app/tests/unit/forms/common/middleware/validateParameter.spec.js index 1af2500c7..6b567d399 100644 --- a/app/tests/unit/forms/common/middleware/validateParameter.spec.js +++ b/app/tests/unit/forms/common/middleware/validateParameter.spec.js @@ -3,8 +3,10 @@ const { v4: uuidv4 } = require('uuid'); const validateParameter = require('../../../../../src/forms/common/middleware/validateParameter'); const formService = require('../../../../../src/forms/form/service'); +const submissionService = require('../../../../../src/forms/submission/service'); const formId = uuidv4(); +const formSubmissionId = uuidv4(); // Various types of invalid UUIDs that we see in API calls. const invalidUuids = [[''], ['undefined'], ['{{id}}'], ['${id}'], [uuidv4() + '.'], [' ' + uuidv4() + ' ']]; @@ -21,7 +23,14 @@ describe('validateDocumentTemplateId', () => { id: documentTemplateId, }; + const mockReadSubmissionResponse = { + form: { + id: formId, + }, + }; + formService.documentTemplateRead = jest.fn().mockReturnValue(mockReadDocumentTemplateResponse); + submissionService.read = jest.fn().mockReturnValue(mockReadSubmissionResponse); describe('400 response when', () => { const expectedStatus = { status: 400 }; @@ -36,8 +45,9 @@ describe('validateDocumentTemplateId', () => { await validateParameter.validateDocumentTemplateId(req, res, next); - expect(formService.documentTemplateRead).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledWith(expect.objectContaining(expectedStatus)); + expect(formService.documentTemplateRead).toBeCalledTimes(0); + expect(submissionService.read).toBeCalledTimes(0); + expect(next).toBeCalledWith(expect.objectContaining(expectedStatus)); }); test.each(invalidUuids)('documentTemplateId is "%s"', async (eachDocumentTemplateId) => { @@ -48,14 +58,30 @@ describe('validateDocumentTemplateId', () => { await validateParameter.validateDocumentTemplateId(req, res, next, eachDocumentTemplateId); - expect(formService.documentTemplateRead).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledWith(expect.objectContaining(expectedStatus)); + expect(formService.documentTemplateRead).toBeCalledTimes(0); + expect(submissionService.read).toBeCalledTimes(0); + expect(next).toBeCalledWith(expect.objectContaining(expectedStatus)); }); }); describe('404 response when', () => { const expectedStatus = { status: 404 }; + test('formId and formSubmissionId are missing', async () => { + const req = getMockReq({ + params: { + documentTemplateId: documentTemplateId, + }, + }); + const { res, next } = getMockRes(); + + await validateParameter.validateDocumentTemplateId(req, res, next, documentTemplateId); + + expect(formService.documentTemplateRead).toBeCalledTimes(0); + expect(submissionService.read).toBeCalledTimes(0); + expect(next).toBeCalledWith(expect.objectContaining(expectedStatus)); + }); + test('formId does not match', async () => { formService.documentTemplateRead.mockReturnValueOnce({ formId: uuidv4(), @@ -71,13 +97,53 @@ describe('validateDocumentTemplateId', () => { await validateParameter.validateDocumentTemplateId(req, res, next, documentTemplateId); - expect(formService.documentTemplateRead).toHaveBeenCalled(); - expect(next).toHaveBeenCalledWith(expect.objectContaining(expectedStatus)); + expect(formService.documentTemplateRead).toBeCalledTimes(1); + expect(submissionService.read).toBeCalledTimes(0); + expect(next).toBeCalledWith(expect.objectContaining(expectedStatus)); + }); + + test('submission formId does not match', async () => { + submissionService.read.mockReturnValueOnce({ + form: { + id: uuidv4(), + }, + }); + const req = getMockReq({ + params: { + formSubmissionId: formSubmissionId, + documentTemplateId: documentTemplateId, + }, + }); + const { res, next } = getMockRes(); + + await validateParameter.validateDocumentTemplateId(req, res, next, documentTemplateId); + + expect(formService.documentTemplateRead).toBeCalledTimes(1); + expect(submissionService.read).toBeCalledTimes(1); + expect(next).toBeCalledWith(expect.objectContaining(expectedStatus)); }); }); describe('handles error thrown by', () => { - test('documentTemplateRead', async () => { + test('submissionService.read', async () => { + const error = new Error(); + submissionService.read.mockRejectedValueOnce(error); + const req = getMockReq({ + params: { + formSubmissionId: formSubmissionId, + documentTemplateId: documentTemplateId, + }, + }); + const { res, next } = getMockRes(); + + await validateParameter.validateDocumentTemplateId(req, res, next, documentTemplateId); + + expect(formService.documentTemplateRead).toBeCalledTimes(0); + expect(submissionService.read).toBeCalledTimes(1); + expect(next).toBeCalledWith(error); + }); + + test('formService.documentTemplateRead', async () => { const error = new Error(); formService.documentTemplateRead.mockRejectedValueOnce(error); const req = getMockReq({ @@ -90,8 +156,9 @@ describe('validateDocumentTemplateId', () => { await validateParameter.validateDocumentTemplateId(req, res, next, documentTemplateId); - expect(formService.documentTemplateRead).toHaveBeenCalled(); - expect(next).toHaveBeenCalledWith(error); + expect(formService.documentTemplateRead).toBeCalledTimes(1); + expect(submissionService.read).toBeCalledTimes(0); + expect(next).toBeCalledWith(error); }); }); @@ -107,8 +174,25 @@ describe('validateDocumentTemplateId', () => { await validateParameter.validateDocumentTemplateId(req, res, next, documentTemplateId); - expect(formService.documentTemplateRead).toHaveBeenCalled(); - expect(next).toHaveBeenCalledWith(); + expect(formService.documentTemplateRead).toBeCalledTimes(1); + expect(submissionService.read).toBeCalledTimes(0); + expect(next).toBeCalledWith(); + }); + + test('document template with matching submission form id', async () => { + const req = getMockReq({ + params: { + formSubmissionId: formSubmissionId, + documentTemplateId: documentTemplateId, + }, + }); + const { res, next } = getMockRes(); + + await validateParameter.validateDocumentTemplateId(req, res, next, documentTemplateId); + + expect(formService.documentTemplateRead).toBeCalledTimes(1); + expect(submissionService.read).toBeCalledTimes(1); + expect(next).toBeCalledWith(); }); }); }); @@ -125,7 +209,7 @@ describe('validateFormId', () => { await validateParameter.validateFormId(req, res, next); - expect(next).toHaveBeenCalledWith(expect.objectContaining(expectedStatus)); + expect(next).toBeCalledWith(expect.objectContaining(expectedStatus)); }); test.each(invalidUuids)('formId is "%s"', async (eachFormId) => { @@ -136,7 +220,7 @@ describe('validateFormId', () => { await validateParameter.validateFormId(req, res, next, eachFormId); - expect(next).toHaveBeenCalledWith(expect.objectContaining(expectedStatus)); + expect(next).toBeCalledWith(expect.objectContaining(expectedStatus)); }); }); @@ -151,7 +235,7 @@ describe('validateFormId', () => { await validateParameter.validateFormId(req, res, next, formId); - expect(next).toHaveBeenCalledWith(); + expect(next).toBeCalledWith(); }); }); }); @@ -179,8 +263,8 @@ describe('validateFormVersionDraftId', () => { await validateParameter.validateFormVersionDraftId(req, res, next); - expect(formService.readDraft).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledWith(expect.objectContaining(expectedStatus)); + expect(formService.readDraft).toBeCalledTimes(0); + expect(next).toBeCalledWith(expect.objectContaining(expectedStatus)); }); test.each(invalidUuids)('formVersionDraftId is "%s"', async (eachFormVersionDraftId) => { @@ -191,14 +275,28 @@ describe('validateFormVersionDraftId', () => { await validateParameter.validateFormVersionDraftId(req, res, next, eachFormVersionDraftId); - expect(formService.readDraft).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledWith(expect.objectContaining(expectedStatus)); + expect(formService.readDraft).toBeCalledTimes(0); + expect(next).toBeCalledWith(expect.objectContaining(expectedStatus)); }); }); describe('404 response when', () => { const expectedStatus = { status: 404 }; + test('formId is missing', async () => { + const req = getMockReq({ + params: { + formVersionDraftId: formVersionDraftId, + }, + }); + const { res, next } = getMockRes(); + + await validateParameter.validateFormVersionDraftId(req, res, next, formVersionDraftId); + + expect(formService.readDraft).toBeCalledTimes(1); + expect(next).toBeCalledWith(expect.objectContaining(expectedStatus)); + }); + test('formId does not match', async () => { formService.readDraft.mockReturnValueOnce({ formId: uuidv4(), @@ -214,8 +312,8 @@ describe('validateFormVersionDraftId', () => { await validateParameter.validateFormVersionDraftId(req, res, next, formVersionDraftId); - expect(formService.readDraft).toHaveBeenCalled(); - expect(next).toHaveBeenCalledWith(expect.objectContaining(expectedStatus)); + expect(formService.readDraft).toBeCalledTimes(1); + expect(next).toBeCalledWith(expect.objectContaining(expectedStatus)); }); }); @@ -233,8 +331,8 @@ describe('validateFormVersionDraftId', () => { await validateParameter.validateFormVersionDraftId(req, res, next, formVersionDraftId); - expect(formService.readDraft).toHaveBeenCalled(); - expect(next).toHaveBeenCalledWith(error); + expect(formService.readDraft).toBeCalledTimes(1); + expect(next).toBeCalledWith(error); }); }); @@ -250,8 +348,8 @@ describe('validateFormVersionDraftId', () => { await validateParameter.validateFormVersionDraftId(req, res, next, formVersionDraftId); - expect(formService.readDraft).toHaveBeenCalled(); - expect(next).toHaveBeenCalledWith(); + expect(formService.readDraft).toBeCalledTimes(1); + expect(next).toBeCalledWith(); }); }); }); @@ -279,8 +377,8 @@ describe('validateFormVersionId', () => { await validateParameter.validateFormVersionId(req, res, next); - expect(formService.readVersion).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledWith(expect.objectContaining(expectedStatus)); + expect(formService.readVersion).toBeCalledTimes(0); + expect(next).toBeCalledWith(expect.objectContaining(expectedStatus)); }); test.each(invalidUuids)('formVersionId is "%s"', async (eachFormVersionId) => { @@ -291,14 +389,28 @@ describe('validateFormVersionId', () => { await validateParameter.validateFormVersionId(req, res, next, eachFormVersionId); - expect(formService.readVersion).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledWith(expect.objectContaining(expectedStatus)); + expect(formService.readVersion).toBeCalledTimes(0); + expect(next).toBeCalledWith(expect.objectContaining(expectedStatus)); }); }); describe('404 response when', () => { const expectedStatus = { status: 404 }; + test('formId is missing', async () => { + const req = getMockReq({ + params: { + formVersionId: formVersionId, + }, + }); + const { res, next } = getMockRes(); + + await validateParameter.validateFormVersionId(req, res, next, formVersionId); + + expect(formService.readVersion).toBeCalledTimes(1); + expect(next).toBeCalledWith(expect.objectContaining(expectedStatus)); + }); + test('formId does not match', async () => { formService.readVersion.mockReturnValueOnce({ formId: uuidv4(), @@ -314,8 +426,8 @@ describe('validateFormVersionId', () => { await validateParameter.validateFormVersionId(req, res, next, formVersionId); - expect(formService.readVersion).toHaveBeenCalled(); - expect(next).toHaveBeenCalledWith(expect.objectContaining(expectedStatus)); + expect(formService.readVersion).toBeCalledTimes(1); + expect(next).toBeCalledWith(expect.objectContaining(expectedStatus)); }); }); @@ -333,8 +445,8 @@ describe('validateFormVersionId', () => { await validateParameter.validateFormVersionId(req, res, next, formVersionId); - expect(formService.readVersion).toHaveBeenCalled(); - expect(next).toHaveBeenCalledWith(error); + expect(formService.readVersion).toBeCalledTimes(1); + expect(next).toBeCalledWith(error); }); }); @@ -350,8 +462,8 @@ describe('validateFormVersionId', () => { await validateParameter.validateFormVersionId(req, res, next, formVersionId); - expect(formService.readVersion).toHaveBeenCalled(); - expect(next).toHaveBeenCalledWith(); + expect(formService.readVersion).toBeCalledTimes(1); + expect(next).toBeCalledWith(); }); }); }); diff --git a/app/tests/unit/forms/submission/controller.spec.js b/app/tests/unit/forms/submission/controller.spec.js index 22d0beac6..8de08e0b4 100644 --- a/app/tests/unit/forms/submission/controller.spec.js +++ b/app/tests/unit/forms/submission/controller.spec.js @@ -1,27 +1,28 @@ -const { getMockRes } = require('@jest-mock/express'); +const { getMockReq, getMockRes } = require('@jest-mock/express'); const { Statuses } = require('../../../../src/forms/common/constants'); const controller = require('../../../../src/forms/submission/controller'); +const formService = require('../../../../src/forms/form/service'); const emailService = require('../../../../src/forms/email/emailService'); const service = require('../../../../src/forms/submission/service'); const cdogsService = require('../../../../src/components/cdogsService'); -const req = { - params: { formSubmissionId: '1' }, - body: { code: Statuses.ASSIGNED }, - currentUser: {}, - headers: { referer: 'a' }, -}; - describe('addStatus', () => { + const req = { + params: { formSubmissionId: '1' }, + body: { code: Statuses.ASSIGNED }, + currentUser: {}, + headers: { referer: 'a' }, + }; + it('should not call email service if no email specified', async () => { service.read = jest.fn().mockReturnValue({ form: { id: '123' } }); service.changeStatusState = jest.fn().mockReturnValue([1, 2, 3]); emailService.statusAssigned = jest.fn().mockReturnValue(true); await controller.addStatus(req, {}, jest.fn()); - expect(service.changeStatusState).toHaveBeenCalledTimes(1); - expect(emailService.statusAssigned).toHaveBeenCalledTimes(0); + expect(service.changeStatusState).toBeCalledTimes(1); + expect(emailService.statusAssigned).toBeCalledTimes(0); }); it('should call email service if an email specified', async () => { @@ -32,9 +33,9 @@ describe('addStatus', () => { emailService.statusAssigned = jest.fn().mockReturnValue(true); await controller.addStatus(req, {}, jest.fn()); - expect(service.changeStatusState).toHaveBeenCalledTimes(1); - expect(emailService.statusAssigned).toHaveBeenCalledTimes(1); - expect(emailService.statusAssigned).toHaveBeenCalledWith('123', 1, 'a@a.com', 'Email Content', 'a'); + expect(service.changeStatusState).toBeCalledTimes(1); + expect(emailService.statusAssigned).toBeCalledTimes(1); + expect(emailService.statusAssigned).toBeCalledWith('123', 1, 'a@a.com', 'Email Content', 'a'); }); it('should call statusRevising if email specified', async () => { @@ -46,9 +47,9 @@ describe('addStatus', () => { emailService.statusRevising = jest.fn().mockReturnValue(true); await controller.addStatus(req, {}, jest.fn()); - expect(service.changeStatusState).toHaveBeenCalledTimes(1); - expect(emailService.statusRevising).toHaveBeenCalledTimes(1); - expect(emailService.statusRevising).toHaveBeenCalledWith('123', 1, 'a@a.com', 'Email content', 'a'); + expect(service.changeStatusState).toBeCalledTimes(1); + expect(emailService.statusRevising).toBeCalledTimes(1); + expect(emailService.statusRevising).toBeCalledWith('123', 1, 'a@a.com', 'Email content', 'a'); }); it('should call statusCompleted if email specified', async () => { @@ -61,98 +62,298 @@ describe('addStatus', () => { emailService.statusCompleted = jest.fn().mockReturnValue(true); await controller.addStatus(req, {}, jest.fn()); - expect(service.changeStatusState).toHaveBeenCalledTimes(1); - expect(emailService.statusCompleted).toHaveBeenCalledTimes(1); - expect(emailService.statusCompleted).toHaveBeenCalledWith('123', 1, 'a@a.com', 'Email Content', 'a'); + expect(service.changeStatusState).toBeCalledTimes(1); + expect(emailService.statusCompleted).toBeCalledTimes(1); + expect(emailService.statusCompleted).toBeCalledWith('123', 1, 'a@a.com', 'Email Content', 'a'); }); }); -describe('templateUploadAndRender', () => { - const confirmationId = '0763A618'; - const content = 'SGVsbG8ge2Quc2ltcGxldGV4dGZpZWxkfSEK'; // Hello {d.simpletextfield}! - const contentFileType = 'txt'; - const outputFileName = 'template_hello_world'; - const outputFileType = 'pdf'; - const submissionId = '0763a618-de57-454b-99cc-3a7c5e992b77'; - - const templateBody = { - body: { - data: { - chefs: { - confirmationId: confirmationId, - submissionId: submissionId, +describe('template rendering', () => { + // Define some valid request and response data. The repetition here, rather + // than pulling data from other objects, is to improve readability. + + const validCdogsTemplate = { + options: { + convertTo: 'pdf', + overwrite: true, + reportName: 'template_hello_world', + }, + template: { + content: btoa('Hello {d.simpletextfield}!'), + encodingType: 'base64', + fileType: 'txt', + }, + }; + + const validSubmission = { + submission: { + confirmationId: '0763A618', + id: '0763a618-de57-454b-99cc-3a7c5e992b77', + submission: { + data: { + simpletextfield: 'firstName lastName', + submit: true, }, - simpletextfield: 'firstName lastName', - submit: true, }, - options: { - reportName: outputFileName, - convertTo: outputFileType, - overwrite: true, - }, - template: { - content: content, - encodingType: 'base64', - fileType: contentFileType, + }, + version: { + version: 1, + }, + }; + + const validCdogsRequest = { + data: { + chefs: { + confirmationId: '0763A618', + formVersion: 1, + submissionId: '0763a618-de57-454b-99cc-3a7c5e992b77', }, + simpletextfield: 'firstName lastName', + submit: true, + }, + options: { + convertTo: 'pdf', + overwrite: true, + reportName: 'template_hello_world', + }, + template: { + content: btoa('Hello {d.simpletextfield}!'), + encodingType: 'base64', + fileType: 'txt', }, }; - const templateReq = { ...req, ...templateBody }; + const validDocumentTemplate = { + filename: 'template_hello_world.txt', + template: 'Hello {d.simpletextfield}!', + }; const mockCdogsResponse = { data: {}, - headers: {}, + headers: { + 'content-disposition': 'attachment; filename=template_hello_world.pdf', + }, status: 200, }; - const mockTemplateReadResponse = { - form: { - id: '123', - }, - submission: { - confirmationId: confirmationId, - id: submissionId, - submission: { - data: { - simpletextfield: 'firstName lastName', - submit: true, + describe('draftTemplateUploadAndRender', () => { + // A draft submission won't have an id or confirmationId. + const validDraftSubmission = structuredClone(validSubmission); + delete validDraftSubmission.submission.confirmationId; + delete validDraftSubmission.submission.id; + + // A draft request won't have the custom "chefs" data. + const validDraftCdogsRequest = structuredClone(validCdogsRequest); + delete validDraftCdogsRequest.data.chefs; + + const validRequest = { + body: { + template: { + ...validCdogsTemplate, }, + ...validDraftSubmission.submission, }, - }, - }; + }; + + describe('error response when', () => { + test('request is missing body', async () => { + cdogsService.templateUploadAndRender = jest.fn().mockReturnValue(mockCdogsResponse); + const req = getMockReq(); + const { res, next } = getMockRes(); + + await controller.draftTemplateUploadAndRender(req, res, next); + + expect(cdogsService.templateUploadAndRender).toBeCalledTimes(0); + expect(res.send).toBeCalledTimes(0); + expect(res.set).toBeCalledTimes(0); + expect(res.status).toBeCalledTimes(0); + expect(next).toBeCalledWith(expect.any(TypeError)); + }); + }); + + describe('200 response when', () => { + test('request is valid', async () => { + cdogsService.templateUploadAndRender = jest.fn().mockReturnValue(mockCdogsResponse); + const req = getMockReq(validRequest); + const { res, next } = getMockRes(); + + await controller.draftTemplateUploadAndRender(req, res, next); + + expect(cdogsService.templateUploadAndRender).toBeCalledTimes(1); + expect(cdogsService.templateUploadAndRender).toBeCalledWith(validDraftCdogsRequest); + expect(res.send).toBeCalledTimes(1); + expect(res.set).toBeCalledWith( + expect.objectContaining({ + 'Content-Disposition': 'attachment; filename=template_hello_world.pdf', + }) + ); + expect(res.status).toBeCalledWith(200); + }); + + test('cdogs response has no content disposition', async () => { + const cdogsResponse = structuredClone(mockCdogsResponse); + delete cdogsResponse.headers['content-disposition']; + cdogsService.templateUploadAndRender = jest.fn().mockReturnValue(cdogsResponse); + const req = getMockReq(validRequest); + const { res, next } = getMockRes(); + + await controller.draftTemplateUploadAndRender(req, res, next); + + expect(cdogsService.templateUploadAndRender).toBeCalledTimes(1); + expect(cdogsService.templateUploadAndRender).toBeCalledWith(validDraftCdogsRequest); + expect(res.send).toBeCalledTimes(1); + expect(res.set).toBeCalledWith( + expect.objectContaining({ + 'Content-Disposition': 'attachment', + }) + ); + expect(res.status).toBeCalledWith(200); + }); + }); + }); + + describe('templateRender', () => { + const validRequest = { + body: { + ...validCdogsTemplate, + }, + params: { + formSubmissionId: validSubmission.submission.id, + }, + }; + + describe('error response when', () => { + test('unsuccessful service call', async () => { + const error = new Error(); + service.read = jest.fn().mockRejectedValue(error); + cdogsService.templateUploadAndRender = jest.fn().mockReturnValue(mockCdogsResponse); + const req = getMockReq(validRequest); + const { res, next } = getMockRes(); - it('should call cdogs service if a body specified', async () => { - service.read = jest.fn().mockReturnValue(mockTemplateReadResponse); - cdogsService.templateUploadAndRender = jest.fn().mockReturnValue(mockCdogsResponse); - const { res } = getMockRes(); + await controller.templateRender(req, res, next); - await controller.templateUploadAndRender(templateReq, res, jest.fn()); + expect(cdogsService.templateUploadAndRender).toBeCalledTimes(0); + expect(res.send).toBeCalledTimes(0); + expect(res.set).toBeCalledTimes(0); + expect(res.status).toBeCalledTimes(0); + expect(next).toBeCalledWith(error); + }); + }); - expect(cdogsService.templateUploadAndRender).toHaveBeenCalledTimes(1); - expect(cdogsService.templateUploadAndRender).toHaveBeenCalledWith(templateReq.body); - expect(res.set).toHaveBeenCalledWith( - expect.objectContaining({ - 'Content-Disposition': 'attachment', - }) - ); + describe('200 response when', () => { + test('request is valid', async () => { + service.read = jest.fn().mockReturnValue(validSubmission); + formService.documentTemplateRead = jest.fn().mockReturnValue(validDocumentTemplate); + cdogsService.templateUploadAndRender = jest.fn().mockReturnValue(mockCdogsResponse); + const req = getMockReq(validRequest); + const { res, next } = getMockRes(); + + await controller.templateRender(req, res, next); + + expect(cdogsService.templateUploadAndRender).toBeCalledTimes(1); + expect(cdogsService.templateUploadAndRender).toBeCalledWith(validCdogsRequest); + expect(res.send).toBeCalledTimes(1); + expect(res.set).toBeCalledWith( + expect.objectContaining({ + 'Content-Disposition': 'attachment; filename=template_hello_world.pdf', + }) + ); + expect(res.status).toBeCalledWith(200); + }); + + test('cdogs response has no content disposition', async () => { + service.read = jest.fn().mockReturnValue(validSubmission); + formService.documentTemplateRead = jest.fn().mockReturnValue(validDocumentTemplate); + const cdogsResponse = structuredClone(mockCdogsResponse); + delete cdogsResponse.headers['content-disposition']; + cdogsService.templateUploadAndRender = jest.fn().mockReturnValue(cdogsResponse); + const req = getMockReq(validRequest); + const { res, next } = getMockRes(); + + await controller.templateRender(req, res, next); + + expect(cdogsService.templateUploadAndRender).toBeCalledTimes(1); + expect(cdogsService.templateUploadAndRender).toBeCalledWith(validCdogsRequest); + expect(res.send).toBeCalledTimes(1); + expect(res.set).toBeCalledWith( + expect.objectContaining({ + 'Content-Disposition': 'attachment', + }) + ); + expect(res.status).toBeCalledWith(200); + }); + }); }); - it('should use the cdogs content disposition when it exists', async () => { - service.read = jest.fn().mockReturnValue(mockTemplateReadResponse); - mockCdogsResponse.headers['content-disposition'] = 'attachment; filename=template_hello_world.pdf'; - cdogsService.templateUploadAndRender = jest.fn().mockReturnValue(mockCdogsResponse); - const { res } = getMockRes(); - - await controller.templateUploadAndRender(templateReq, res, jest.fn()); - - expect(cdogsService.templateUploadAndRender).toHaveBeenCalledTimes(1); - expect(cdogsService.templateUploadAndRender).toHaveBeenCalledWith(templateReq.body); - expect(res.set).toHaveBeenCalledWith( - expect.objectContaining({ - 'Content-Disposition': 'attachment; filename=template_hello_world.pdf', - }) - ); + describe('templateUploadAndRender', () => { + const validRequest = { + body: { + ...validCdogsTemplate, + }, + params: { + formSubmissionId: validSubmission.submission.id, + }, + }; + + describe('error response when', () => { + test('unsuccessful service call', async () => { + const error = new Error(); + service.read = jest.fn().mockRejectedValue(error); + cdogsService.templateUploadAndRender = jest.fn().mockReturnValue(mockCdogsResponse); + const req = getMockReq(validRequest); + const { res, next } = getMockRes(); + + await controller.templateUploadAndRender(req, res, next); + + expect(cdogsService.templateUploadAndRender).toBeCalledTimes(0); + expect(res.send).toBeCalledTimes(0); + expect(res.set).toBeCalledTimes(0); + expect(res.status).toBeCalledTimes(0); + expect(next).toBeCalledWith(error); + }); + }); + + describe('200 response when', () => { + test('request is valid', async () => { + service.read = jest.fn().mockReturnValue(validSubmission); + cdogsService.templateUploadAndRender = jest.fn().mockReturnValue(mockCdogsResponse); + const req = getMockReq(validRequest); + const { res, next } = getMockRes(); + + await controller.templateUploadAndRender(req, res, next); + + expect(cdogsService.templateUploadAndRender).toBeCalledTimes(1); + expect(cdogsService.templateUploadAndRender).toBeCalledWith(validCdogsRequest); + expect(res.send).toBeCalledTimes(1); + expect(res.set).toBeCalledWith( + expect.objectContaining({ + 'Content-Disposition': 'attachment; filename=template_hello_world.pdf', + }) + ); + expect(res.status).toBeCalledWith(200); + }); + + test('cdogs response has no content disposition', async () => { + service.read = jest.fn().mockReturnValue(validSubmission); + const cdogsResponse = structuredClone(mockCdogsResponse); + delete cdogsResponse.headers['content-disposition']; + cdogsService.templateUploadAndRender = jest.fn().mockReturnValue(cdogsResponse); + const req = getMockReq(validRequest); + const { res, next } = getMockRes(); + + await controller.templateUploadAndRender(req, res, next); + + expect(cdogsService.templateUploadAndRender).toBeCalledTimes(1); + expect(cdogsService.templateUploadAndRender).toBeCalledWith(validCdogsRequest); + expect(res.send).toBeCalledTimes(1); + expect(res.set).toBeCalledWith( + expect.objectContaining({ + 'Content-Disposition': 'attachment', + }) + ); + expect(res.status).toBeCalledWith(200); + }); + }); }); }); @@ -176,8 +377,8 @@ describe('deleteMutipleSubmissions', () => { service.deleteMutipleSubmissions = jest.fn().mockReturnValue(returnValue); await controller.deleteMutipleSubmissions(req, {}, jest.fn()); - expect(service.deleteMutipleSubmissions).toHaveBeenCalledTimes(1); - expect(service.deleteMutipleSubmissions).toHaveBeenCalledWith(req.body.submissionIds, req.currentUser); + expect(service.deleteMutipleSubmissions).toBeCalledTimes(1); + expect(service.deleteMutipleSubmissions).toBeCalledWith(req.body.submissionIds, req.currentUser); }); }); @@ -201,7 +402,7 @@ describe('restoreMutipleSubmissions', () => { service.restoreMutipleSubmissions = jest.fn().mockReturnValue(returnValue); await controller.restoreMutipleSubmissions(req, {}, jest.fn()); - expect(service.restoreMutipleSubmissions).toHaveBeenCalledTimes(1); - expect(service.restoreMutipleSubmissions).toHaveBeenCalledWith(req.body.submissionIds, req.currentUser); + expect(service.restoreMutipleSubmissions).toBeCalledTimes(1); + expect(service.restoreMutipleSubmissions).toBeCalledWith(req.body.submissionIds, req.currentUser); }); }); diff --git a/app/tests/unit/forms/submission/routes.spec.js b/app/tests/unit/forms/submission/routes.spec.js new file mode 100644 index 000000000..2bb860fb8 --- /dev/null +++ b/app/tests/unit/forms/submission/routes.spec.js @@ -0,0 +1,72 @@ +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/submission/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.templateRender = jest.fn((_req, _res, next) => { + next(); +}); + +rateLimiter.apiKeyRateLimiter = jest.fn((_req, _res, next) => { + next(); +}); + +const hasSubmissionPermissionsMock = jest.fn((_req, _res, next) => { + next(); +}); + +userAccess.hasSubmissionPermissions = jest.fn(() => { + return hasSubmissionPermissionsMock; +}); + +validateParameter.validateDocumentTemplateId = jest.fn((_req, _res, next) => { + next(); +}); + +const documentTemplateId = uuidv4(); +const formSubmissionId = uuidv4(); + +// +// Create the router and a simple Express server. +// + +const router = require('../../../../src/forms/submission/routes'); +const basePath = '/submissions'; +const app = expressHelper(basePath, router); +const appRequest = request(app); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe(`${basePath}/:formSubmissionId/template/:documentTemplateId/render`, () => { + const path = `${basePath}/${formSubmissionId}/template/${documentTemplateId}/render`; + + it('should have correct middleware for GET', async () => { + await appRequest.get(path); + + expect(validateParameter.validateDocumentTemplateId).toHaveBeenCalledTimes(1); + expect(apiAccess).toHaveBeenCalledTimes(1); + expect(rateLimiter.apiKeyRateLimiter).toHaveBeenCalledTimes(1); + expect(hasSubmissionPermissionsMock).toHaveBeenCalledTimes(1); + expect(controller.templateRender).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/tests/unit/routes/v1/submission.spec.js b/app/tests/unit/routes/v1/submission.spec.js index c943e6b6c..de2b1e0ad 100644 --- a/app/tests/unit/routes/v1/submission.spec.js +++ b/app/tests/unit/routes/v1/submission.spec.js @@ -588,6 +588,9 @@ describe(`${basePath}/:formSubmissionId/template/render`, () => { submission: { submission: {}, }, + version: { + version: 1, + }, }; const renderResponse = { headers: {},