From ebebe2609f9a1cc63580373ef114876638cc5287 Mon Sep 17 00:00:00 2001 From: wanlingt <wanling@open.gov.sg> Date: Wed, 10 May 2023 18:37:50 +0800 Subject: [PATCH 1/8] feat: add createSampleSubmissionData function --- src/app/modules/form/form.service.ts | 49 ++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index 6d4d693038..f0a2f06f58 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -3,6 +3,7 @@ import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' import { FormAuthType, + FormFieldDto, FormResponseMode, FormStatus, } from '../../../../shared/types' @@ -343,3 +344,51 @@ export const retrievePublicFormsWithSmsVerification = ( return okAsync(forms) }) } + +export const createSampleSubmissionData = ( + sampleData: Record<string, unknown>, + field: FormFieldDto, +) => { + let sampleValue = null + switch (field.fieldType) { + case 'textarea': + case 'textfield': + sampleValue = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.' + break + case 'radiobutton': + case 'dropdown': + sampleValue = field.fieldOptions[0] + break + case 'email': + sampleValue = 'hello@example.com' + break + case 'decimal': + sampleValue = 1.234 + break + case 'number': + sampleValue = 1234 + break + case 'mobile': + sampleValue = '+6598765432' + break + case 'homeno': + sampleValue = '+6567890123' + break + case 'yes_no': + sampleValue = 'yes' + break + case 'rating': + sampleValue = 1 + break + default: + break + } + if (sampleValue != null) { + sampleData[field._id] = { + question: field.title, + answer: sampleValue, + } + } + return sampleValue +} From 0d582b1e0b4363d52f5f762a46e81d28f49fb21b Mon Sep 17 00:00:00 2001 From: wanlingt <wanling@open.gov.sg> Date: Wed, 10 May 2023 18:39:00 +0800 Subject: [PATCH 2/8] feat: add endpoint to get sample submission --- .../public-form/public-form.controller.ts | 56 +++++++++++++++++++ .../api/v3/forms/public-forms.form.routes.ts | 4 ++ 2 files changed, 60 insertions(+) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index 3c69c27eef..04998ff26f 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -326,6 +326,62 @@ export const handleGetPublicForm: ControllerHandler< } } +export const handleGetPublicFormSampleSubmission: ControllerHandler< + { formId: string }, + Record<string, any> | ErrorDto | PrivateFormErrorDto +> = async (req, res) => { + const { formId } = req.params + const logMeta = { + action: 'handleGetPublicFormSampleSubmission', + ...createReqMeta(req), + formId, + } + + const formResult = await getFormIfPublic(formId).andThen((form) => + FormService.checkFormSubmissionLimitAndDeactivateForm(form), + ) + // Early return if form is not public or any error occurred. + if (formResult.isErr()) { + const { error } = formResult + // NOTE: Only log on possible database errors. + // This is because the other kinds of errors are expected errors and are not truly exceptional + if (isMongoError(error)) { + logger.error({ + message: 'Error retrieving public form', + meta: logMeta, + error, + }) + } + const { errorMessage, statusCode } = mapRouteError(error) + + // Specialized error response for PrivateFormError. + // This is to maintain backwards compatibility with the middleware implementation + if (error instanceof PrivateFormError) { + return res.status(statusCode).json({ + message: error.message, + // Flag to prevent default 404 subtext ("please check link") from + // showing. + isPageFound: true, + formTitle: error.formTitle, + }) + } + + return res.status(statusCode).json({ message: errorMessage }) + } + const form = formResult.value + + const publicForm = form.getPublicView() as PublicFormDto + + const sampleData: Record<string, any> = {} + const formFields = publicForm.form_fields + if (!formFields) { + throw new Error('unable to get form fields') + } + for (const field of formFields) { + FormService.createSampleSubmissionData(sampleData, field) + } + return res.json({ responses: sampleData }) +} /** * NOTE: This is exported only for testing * Generates redirect URL to Official SingPass/CorpPass log in page diff --git a/src/app/routes/api/v3/forms/public-forms.form.routes.ts b/src/app/routes/api/v3/forms/public-forms.form.routes.ts index b838984b35..e36312eabb 100644 --- a/src/app/routes/api/v3/forms/public-forms.form.routes.ts +++ b/src/app/routes/api/v3/forms/public-forms.form.routes.ts @@ -24,6 +24,10 @@ PublicFormsFormRouter.route('/:formId([a-fA-F0-9]{24})').get( PublicFormController.handleGetPublicForm, ) +PublicFormsFormRouter.route('/:formId([a-fA-F0-9]{24})/sample-submission').get( + PublicFormController.handleGetPublicFormSampleSubmission, +) + // TODO #4279: Remove after React rollout is complete /** * Returns the React to Angular switch feedback form to the user From cb60d3c9228cdb47a65dba1031ff74c697541f70 Mon Sep 17 00:00:00 2001 From: wanlingt <wanling@open.gov.sg> Date: Mon, 15 May 2023 16:20:02 +0800 Subject: [PATCH 3/8] docs: add comments for GET sample submission endpoint --- .../routes/api/v3/forms/public-forms.form.routes.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/app/routes/api/v3/forms/public-forms.form.routes.ts b/src/app/routes/api/v3/forms/public-forms.form.routes.ts index e36312eabb..dc728e78f7 100644 --- a/src/app/routes/api/v3/forms/public-forms.form.routes.ts +++ b/src/app/routes/api/v3/forms/public-forms.form.routes.ts @@ -24,6 +24,16 @@ PublicFormsFormRouter.route('/:formId([a-fA-F0-9]{24})').get( PublicFormController.handleGetPublicForm, ) +/** + * Returns a sample submission response of the specified form to the user + * + * @route GET /:formId/sample-submission + * + * @returns 200 with form when form exists and is public + * @returns 404 when form is private or form with given ID does not exist + * @returns 410 when form is archived + * @returns 500 when database error occurs + */ PublicFormsFormRouter.route('/:formId([a-fA-F0-9]{24})/sample-submission').get( PublicFormController.handleGetPublicFormSampleSubmission, ) From 473614acfc2d7b372e035f738941b59fd9bda83b Mon Sep 17 00:00:00 2001 From: wanlingt <wanling@open.gov.sg> Date: Mon, 15 May 2023 16:20:34 +0800 Subject: [PATCH 4/8] feat: randomise response, add attachment type --- src/app/modules/form/form.service.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index f0a2f06f58..9d03408058 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -350,6 +350,8 @@ export const createSampleSubmissionData = ( field: FormFieldDto, ) => { let sampleValue = null + let noOfOptions = 0 + let randomSelectedOption = 0 switch (field.fieldType) { case 'textarea': case 'textfield': @@ -358,7 +360,9 @@ export const createSampleSubmissionData = ( break case 'radiobutton': case 'dropdown': - sampleValue = field.fieldOptions[0] + noOfOptions = field.fieldOptions.length + randomSelectedOption = Math.floor(Math.random() * noOfOptions) + sampleValue = field.fieldOptions[randomSelectedOption] break case 'email': sampleValue = 'hello@example.com' @@ -381,6 +385,9 @@ export const createSampleSubmissionData = ( case 'rating': sampleValue = 1 break + case 'attachment': + sampleValue = 'attachmentFileName' + break default: break } @@ -388,6 +395,7 @@ export const createSampleSubmissionData = ( sampleData[field._id] = { question: field.title, answer: sampleValue, + fieldType: field.fieldType, } } return sampleValue From a4ac6a249e8d926d01ee7dc056ae06f1b3c137da Mon Sep 17 00:00:00 2001 From: wanlingt <wanling@open.gov.sg> Date: Mon, 15 May 2023 17:42:01 +0800 Subject: [PATCH 5/8] fix: remove form deactivation check --- src/app/modules/form/public-form/public-form.controller.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index 04998ff26f..c490f81973 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -337,9 +337,7 @@ export const handleGetPublicFormSampleSubmission: ControllerHandler< formId, } - const formResult = await getFormIfPublic(formId).andThen((form) => - FormService.checkFormSubmissionLimitAndDeactivateForm(form), - ) + const formResult = await getFormIfPublic(formId) // Early return if form is not public or any error occurred. if (formResult.isErr()) { const { error } = formResult From d039a65e152052e9c5af50be2771af79f979acf7 Mon Sep 17 00:00:00 2001 From: wanlingt <wanling@open.gov.sg> Date: Mon, 15 May 2023 17:43:24 +0800 Subject: [PATCH 6/8] tests: add unit tests for sample submission endpoint --- .../public-forms.form.routes.spec.ts | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/src/app/routes/api/v3/forms/__tests__/public-forms.form.routes.spec.ts b/src/app/routes/api/v3/forms/__tests__/public-forms.form.routes.spec.ts index 22f1703f26..80101ac020 100644 --- a/src/app/routes/api/v3/forms/__tests__/public-forms.form.routes.spec.ts +++ b/src/app/routes/api/v3/forms/__tests__/public-forms.form.routes.spec.ts @@ -5,6 +5,7 @@ import { errAsync } from 'neverthrow' import supertest, { Session } from 'supertest-session' import { DatabaseError } from 'src/app/modules/core/core.errors' +import { createSampleSubmissionData } from 'src/app/modules/form/form.service' import { MOCK_ACCESS_TOKEN, MOCK_AUTH_CODE, @@ -317,4 +318,122 @@ describe('public-form.form.routes', () => { expect(actualResponse.body).toEqual(expectedResponseBody) }) }) + + describe('GET /:formId/sample-submission', () => { + it('should return 200 with public form when form has a valid formId', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { status: FormStatus.Public }, + }) + // NOTE: This is needed to inject admin info into the form + const fullForm = await dbHandler.getFullFormById(form._id) + expect(fullForm).not.toBeNull() + + const formFields = fullForm?.getPublicView().form_fields + if (!formFields) return + const expectedSampleData = {} + for (const field of formFields) { + createSampleSubmissionData(expectedSampleData, field) + } + const expectedResponseBody = JSON.parse( + JSON.stringify({ + responses: expectedSampleData, + }), + ) + + // Act + const actualResponse = await request.get( + `/forms/${form._id}/sample-submission`, + ) + + // Assert + expect(actualResponse.status).toEqual(200) + expect(actualResponse.body).toEqual(expectedResponseBody) + }) + + it('should return 404 if the form does not exist', async () => { + const MOCK_FORM_ID = new ObjectId().toHexString() + const expectedResponseBody = JSON.parse( + JSON.stringify({ + message: 'Form not found', + }), + ) + + // Act + const actualResponse = await request.get( + `/forms/${MOCK_FORM_ID}/sample-submission`, + ) + + // Assert + expect(actualResponse.status).toEqual(404) + expect(actualResponse.body).toEqual(expectedResponseBody) + }) + + it('should return 404 if the form is private', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { status: FormStatus.Private }, + }) + const expectedResponseBody = JSON.parse( + JSON.stringify({ + message: form.inactiveMessage, + formTitle: form.title, + isPageFound: true, + }), + ) + + // Act + const actualResponse = await request.get( + `/forms/${form._id}/sample-submission`, + ) + + // Assert + expect(actualResponse.status).toEqual(404) + expect(actualResponse.body).toEqual(expectedResponseBody) + }) + + it('should return 410 if the form has been archived', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { status: FormStatus.Archived }, + }) + const expectedResponseBody = JSON.parse( + JSON.stringify({ + message: 'This form is no longer active', + }), + ) + + // Act + const actualResponse = await request.get( + `/forms/${form._id}/sample-submission`, + ) + + // Assert + expect(actualResponse.status).toEqual(410) + expect(actualResponse.body).toEqual(expectedResponseBody) + }) + + it('should return 500 if a database error occurs', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { status: FormStatus.Public }, + }) + const expectedError = new DatabaseError('all your base are belong to us') + const expectedResponseBody = JSON.parse( + JSON.stringify({ message: expectedError.message }), + ) + jest + .spyOn(AuthService, 'getFormIfPublic') + .mockReturnValueOnce(errAsync(expectedError)) + + // Act + const actualResponse = await request.get( + `/forms/${form._id}/sample-submission`, + ) + + // Assert + expect(actualResponse.status).toEqual(500) + expect(actualResponse.body).toEqual(expectedResponseBody) + }) + }) }) From 3f2dcc19ac6a66253f05f172c83cb3dd2d70a458 Mon Sep 17 00:00:00 2001 From: wanlingt <wanling@open.gov.sg> Date: Tue, 16 May 2023 12:00:34 +0800 Subject: [PATCH 7/8] ref: split sample submission creation into smaller functions --- src/app/modules/form/form.service.ts | 24 ++++++++++++++----- .../public-form/public-form.controller.ts | 7 +++--- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index 9d03408058..979d5ec089 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -3,6 +3,7 @@ import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' import { FormAuthType, + FormField, FormFieldDto, FormResponseMode, FormStatus, @@ -345,10 +346,7 @@ export const retrievePublicFormsWithSmsVerification = ( }) } -export const createSampleSubmissionData = ( - sampleData: Record<string, unknown>, - field: FormFieldDto, -) => { +export const createSingleSampleSubmissionAnswer = (field: FormFieldDto) => { let sampleValue = null let noOfOptions = 0 let randomSelectedOption = 0 @@ -391,12 +389,26 @@ export const createSampleSubmissionData = ( default: break } + let answer = {} if (sampleValue != null) { - sampleData[field._id] = { + answer = { + id: field._id, question: field.title, answer: sampleValue, fieldType: field.fieldType, } } - return sampleValue + return answer +} + +export const createSampleSubmissionResponses = ( + formFields: FormFieldDto<FormField>[], +) => { + const sampleData: Record<string, any> = {} + formFields.forEach((field) => { + const answer = createSingleSampleSubmissionAnswer(field) + if (!answer) return + sampleData[field._id] = answer + }) + return sampleData } diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index c490f81973..e368c9435a 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -370,14 +370,13 @@ export const handleGetPublicFormSampleSubmission: ControllerHandler< const publicForm = form.getPublicView() as PublicFormDto - const sampleData: Record<string, any> = {} const formFields = publicForm.form_fields if (!formFields) { throw new Error('unable to get form fields') } - for (const field of formFields) { - FormService.createSampleSubmissionData(sampleData, field) - } + + const sampleData = FormService.createSampleSubmissionResponses(formFields) + return res.json({ responses: sampleData }) } /** From d982f831f401f1651d252f02bd99e0e792e8d65d Mon Sep 17 00:00:00 2001 From: wanlingt <wanling@open.gov.sg> Date: Tue, 16 May 2023 13:47:06 +0800 Subject: [PATCH 8/8] fix: return static fields in createSingleSampleSubmissionAnswer --- src/app/modules/form/form.service.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index 979d5ec089..b4218d75a3 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -389,16 +389,12 @@ export const createSingleSampleSubmissionAnswer = (field: FormFieldDto) => { default: break } - let answer = {} - if (sampleValue != null) { - answer = { - id: field._id, - question: field.title, - answer: sampleValue, - fieldType: field.fieldType, - } + return { + id: field._id, + question: field.title, + answer: sampleValue, + fieldType: field.fieldType, } - return answer } export const createSampleSubmissionResponses = (