diff --git a/src/app/modules/bounce/bounce.service.ts b/src/app/modules/bounce/bounce.service.ts index fa31c17aba..e78b4dbc44 100644 --- a/src/app/modules/bounce/bounce.service.ts +++ b/src/app/modules/bounce/bounce.service.ts @@ -10,6 +10,7 @@ import { ResultAsync, } from 'neverthrow' +import { hasProp } from '../../../shared/util/has-prop' import { BounceType, IBounceSchema, @@ -25,7 +26,6 @@ import { EMAIL_HEADERS, EmailType } from '../../services/mail/mail.constants' import MailService from '../../services/mail/mail.service' import { SmsFactory } from '../../services/sms/sms.factory' import { transformMongoError } from '../../utils/handle-mongo-error' -import { hasProp } from '../../utils/has-prop' import { PossibleDatabaseError } from '../core/core.errors' import { getCollabEmailsWithPermission } from '../form/form.utils' import * as UserService from '../user/user.service' diff --git a/src/app/modules/myinfo/myinfo.util.ts b/src/app/modules/myinfo/myinfo.util.ts index 6f3febbaee..5c5a05b9dd 100644 --- a/src/app/modules/myinfo/myinfo.util.ts +++ b/src/app/modules/myinfo/myinfo.util.ts @@ -6,6 +6,7 @@ import { err, ok, Result } from 'neverthrow' import { v4 as uuidv4, validate as validateUUID } from 'uuid' import { types as myInfoTypes } from '../../../shared/resources/myinfo' +import { hasProp } from '../../../shared/util/has-prop' import { AuthType, BasicField, @@ -16,7 +17,6 @@ import { MapRouteError, } from '../../../types' import { createLoggerWithLabel } from '../../config/logger' -import { hasProp } from '../../utils/has-prop' import { DatabaseError, MissingFeatureError } from '../core/core.errors' import { AuthTypeMismatchError, diff --git a/src/app/modules/spcp/spcp.util.ts b/src/app/modules/spcp/spcp.util.ts index fde8713544..3aba24bbe6 100644 --- a/src/app/modules/spcp/spcp.util.ts +++ b/src/app/modules/spcp/spcp.util.ts @@ -3,6 +3,7 @@ import crypto from 'crypto' import { StatusCodes } from 'http-status-codes' import { err, ok, Result } from 'neverthrow' +import { hasProp } from '../../../shared/util/has-prop' import { AuthType, BasicField, @@ -11,7 +12,6 @@ import { SPCPFieldTitle, } from '../../../types' import { createLoggerWithLabel } from '../../config/logger' -import { hasProp } from '../../utils/has-prop' import { MissingFeatureError } from '../core/core.errors' import { AuthTypeMismatchError, diff --git a/src/public/modules/forms/helpers/CsvMergedHeadersGenerator.js b/src/public/modules/forms/helpers/CsvMergedHeadersGenerator.ts similarity index 63% rename from src/public/modules/forms/helpers/CsvMergedHeadersGenerator.js rename to src/public/modules/forms/helpers/CsvMergedHeadersGenerator.ts index 622e95af67..9e148e92f2 100644 --- a/src/public/modules/forms/helpers/CsvMergedHeadersGenerator.js +++ b/src/public/modules/forms/helpers/CsvMergedHeadersGenerator.ts @@ -1,21 +1,40 @@ -const moment = require('moment-timezone') -const keyBy = require('lodash/keyBy') -const { CsvGenerator } = require('./CsvGenerator') -const { getResponseInstance } = require('./response-factory') - -/** - * @typedef {{ - * _id: string, - * question: string, - * answer?: string, - * answerArray?: string[], - * fieldType: string, - * isHeader?: boolean, - * }} DisplayedResponse - */ - -class CsvMergedHeadersGenerator extends CsvGenerator { - constructor(expectedNumberOfRecords, numOfMetaDataRows) { +import keyBy from 'lodash/keyBy' +import moment from 'moment-timezone' + +import { DisplayedResponseWithoutAnswer } from '../../../../types/response' + +import { Response } from './csv-response-classes' +import { CsvGenerator } from './CsvGenerator' +import { getResponseInstance } from './response-factory' + +type KeyedResponse = { [fieldId: string]: Response } + +type UnprocessedRecord = { + created: string + submissionId: string + record: KeyedResponse +} + +type SubmissionRecord = { + record: DisplayedResponseWithoutAnswer[] + created: string + submissionId: string +} + +export class CsvMergedHeadersGenerator extends CsvGenerator { + hasBeenProcessed: boolean + hasBeenSorted: boolean + fieldIdToQuestion: Map< + string, + { + created: string + question: string + } + > + fieldIdToNumCols: Record + unprocessed: UnprocessedRecord[] + + constructor(expectedNumberOfRecords: number, numOfMetaDataRows: number) { super(expectedNumberOfRecords, numOfMetaDataRows) this.hasBeenProcessed = false @@ -28,25 +47,25 @@ class CsvMergedHeadersGenerator extends CsvGenerator { /** * Returns current length of CSV file excluding header and meta-data */ - length() { + length(): number { return this.unprocessed.length } /** - * - * @param {Object} decryptedContent - * @param {DisplayedResponse[]} decryptedContent.record - * @param {string} decryptedContent.created - * @param {string} decryptedContent.submissionId + * Extracts information from input record, rearranges record and then adds an UnprocessedRecord to this.unprocessed + * @param decryptedContent + * @param decryptedContent.record + * @param decryptedContent.created + * @param decryptedContent.submissionId + * @throws Error when trying to convert record into a response instance. Should be caught in submissions client factory. */ - addRecord({ record, created, submissionId }) { + addRecord({ record, created, submissionId }: SubmissionRecord): void { // First pass, create object with { [fieldId]: question } from // decryptedContent to get all the questions. const fieldRecords = record.map((content) => { const fieldRecord = getResponseInstance(content) if (!fieldRecord.isHeader) { const currentMapping = this.fieldIdToQuestion.get(fieldRecord.id) - // Only set new mapping if it does not exist or this record is a later // submission. // Might need to differentiate the question headers if we allow @@ -77,28 +96,16 @@ class CsvMergedHeadersGenerator extends CsvGenerator { }) } - /** - * Extracts the string representation from an unprocessed record. - * @param {Object} unprocessedRecord - * @param {string} fieldId - * @returns {string} - */ - _extractAnswer(unprocessedRecord, fieldId, colIndex) { - const fieldRecord = unprocessedRecord[fieldId] - if (!fieldRecord) return '' - return fieldRecord.getAnswer(colIndex) - } - /** * Process the unprocessed records by creating the correct headers and * assigning each answer to their respective locations in each response row in * the csv data. */ - process() { + process(): void { if (this.hasBeenProcessed) return // Create a header row in CSV using the fieldIdToQuestion map. - let headers = ['Reference number', 'Timestamp'] + const headers = ['Reference number', 'Timestamp'] this.fieldIdToQuestion.forEach((value, fieldId) => { for (let i = 0; i < this.fieldIdToNumCols[fieldId]; i++) { headers.push(value.question) @@ -109,26 +116,44 @@ class CsvMergedHeadersGenerator extends CsvGenerator { // Craft a new csv row for each unprocessed record // O(qn), where q = number of unique questions, n = number of submissions. this.unprocessed.forEach((up) => { - let createdAt = moment(up.created).tz('Asia/Singapore') - createdAt = createdAt.isValid() + const createdAt = moment(up.created).tz('Asia/Singapore') + const formattedDate = createdAt.isValid() ? createdAt.format('DD MMM YYYY hh:mm:ss A') - : createdAt - let row = [up.submissionId, createdAt] - for (let [fieldId] of this.fieldIdToQuestion) { + : createdAt.toString() // just convert to string if given date is not valid + const row = [up.submissionId, formattedDate] + + this.fieldIdToQuestion.forEach((_question, fieldId) => { const numCols = this.fieldIdToNumCols[fieldId] for (let colIndex = 0; colIndex < numCols; colIndex++) { row.push(this._extractAnswer(up.record, fieldId, colIndex)) } - } + }) this.addLine(row) }) this.hasBeenProcessed = true } + /** + * Extracts the string representation from a field response + * @param unprocessedRecord + * @param fieldId + * @param colIndex + * @returns string representation of unprocessed record + */ + private _extractAnswer( + unprocessedRecord: KeyedResponse, + fieldId: string, + colIndex: number, + ): string { + const fieldRecord = unprocessedRecord[fieldId] + if (!fieldRecord) return '' + return fieldRecord.getAnswer(colIndex) + } + /** * Sorts unprocessed records from oldest to newest */ - sort() { + sort(): void { if (this.hasBeenSorted) return this.unprocessed.sort((a, b) => this._dateComparator(a.created, b.created)) this.hasBeenSorted = true @@ -138,8 +163,8 @@ class CsvMergedHeadersGenerator extends CsvGenerator { * Add meta-data as first three rows of the CSV. If there is already meta-data * added, it will be replaced by the latest counts. */ - addMetaDataFromSubmission(errorCount, unverifiedCount) { - let metaDataRows = [ + addMetaDataFromSubmission(errorCount: number, unverifiedCount: number): void { + const metaDataRows = [ ['Expected total responses', this.expectedNumberOfRecords], ['Success count', this.length()], ['Error count', errorCount], @@ -151,9 +176,9 @@ class CsvMergedHeadersGenerator extends CsvGenerator { /** * Main method to call to retrieve a downloadable csv. - * @param {string} filename + * @param filename name of csv file */ - downloadCsv(filename) { + downloadCsv(filename: string): void { this.sort() this.process() this.triggerFileDownload(filename) @@ -161,10 +186,10 @@ class CsvMergedHeadersGenerator extends CsvGenerator { /** * Comparator for dates - * @param string firstDate - * @param string secondDate + * @param firstDate + * @param secondDate */ - _dateComparator(firstDate, secondDate) { + private _dateComparator(firstDate: string, secondDate: string): number { // cast to Asia/Singapore to ensure both dates are of the same timezone const first = moment(firstDate).tz('Asia/Singapore') const second = moment(secondDate).tz('Asia/Singapore') @@ -178,5 +203,3 @@ class CsvMergedHeadersGenerator extends CsvGenerator { } } } - -module.exports = CsvMergedHeadersGenerator diff --git a/src/public/modules/forms/helpers/csv-response-classes/ArrayAnswerResponse.class.js b/src/public/modules/forms/helpers/csv-response-classes/ArrayAnswerResponse.class.js deleted file mode 100644 index 5b06db10ad..0000000000 --- a/src/public/modules/forms/helpers/csv-response-classes/ArrayAnswerResponse.class.js +++ /dev/null @@ -1,11 +0,0 @@ -const Response = require('./Response.class') - -module.exports = class ArrayAnswerResponse extends Response { - getAnswer() { - return this._data.answerArray.join(';') - } - - get numCols() { - return 1 - } -} diff --git a/src/public/modules/forms/helpers/csv-response-classes/ArrayAnswerResponse.class.ts b/src/public/modules/forms/helpers/csv-response-classes/ArrayAnswerResponse.class.ts new file mode 100644 index 0000000000..f77e486541 --- /dev/null +++ b/src/public/modules/forms/helpers/csv-response-classes/ArrayAnswerResponse.class.ts @@ -0,0 +1,20 @@ +import { ArrayResponse } from '../../../../../types/response' + +import { Response } from './Response.class' + +export class ArrayAnswerResponse extends Response { + private response: ArrayResponse + + constructor(responseData: ArrayResponse) { + super(responseData) + this.response = responseData + } + + getAnswer(): string { + return this.response.answerArray.join(';') + } + + get numCols(): number { + return 1 + } +} diff --git a/src/public/modules/forms/helpers/csv-response-classes/Response.class.js b/src/public/modules/forms/helpers/csv-response-classes/Response.class.js deleted file mode 100644 index c10cb850f4..0000000000 --- a/src/public/modules/forms/helpers/csv-response-classes/Response.class.js +++ /dev/null @@ -1,21 +0,0 @@ -module.exports = class Response { - constructor(responseData) { - this._data = responseData - } - - get id() { - return this._data._id - } - - /** - * Gets the CSV header. - * @returns {string} - */ - get question() { - return this._data.question - } - - get isHeader() { - return this._data.isHeader - } -} diff --git a/src/public/modules/forms/helpers/csv-response-classes/Response.class.ts b/src/public/modules/forms/helpers/csv-response-classes/Response.class.ts new file mode 100644 index 0000000000..9f2c1d62bc --- /dev/null +++ b/src/public/modules/forms/helpers/csv-response-classes/Response.class.ts @@ -0,0 +1,29 @@ +import { DisplayedResponseWithoutAnswer } from '../../../../../types/response' + +export abstract class Response { + private data: DisplayedResponseWithoutAnswer + + constructor(responseData: DisplayedResponseWithoutAnswer) { + this.data = responseData + } + + get id(): string { + return this.data._id + } + + /** + * Gets the CSV header. + * @returns {string} + */ + get question(): string { + return this.data.question + } + + get isHeader(): boolean { + return this.data.isHeader ?? false + } + + abstract get numCols(): number + + abstract getAnswer(colIndex?: number): string +} diff --git a/src/public/modules/forms/helpers/csv-response-classes/SingleAnswerResponse.class.js b/src/public/modules/forms/helpers/csv-response-classes/SingleAnswerResponse.class.js deleted file mode 100644 index fecb3d1d7e..0000000000 --- a/src/public/modules/forms/helpers/csv-response-classes/SingleAnswerResponse.class.js +++ /dev/null @@ -1,11 +0,0 @@ -const Response = require('./Response.class') - -module.exports = class SingleAnswerResponse extends Response { - getAnswer() { - return this._data.answer - } - - get numCols() { - return 1 - } -} diff --git a/src/public/modules/forms/helpers/csv-response-classes/SingleAnswerResponse.class.ts b/src/public/modules/forms/helpers/csv-response-classes/SingleAnswerResponse.class.ts new file mode 100644 index 0000000000..f495544e3f --- /dev/null +++ b/src/public/modules/forms/helpers/csv-response-classes/SingleAnswerResponse.class.ts @@ -0,0 +1,20 @@ +import { SingleResponse } from '../../../../../types/response' + +import { Response } from './Response.class' + +export class SingleAnswerResponse extends Response { + private response: SingleResponse + + constructor(responseData: SingleResponse) { + super(responseData) + this.response = responseData + } + + getAnswer(): string { + return this.response.answer + } + + get numCols(): number { + return 1 + } +} diff --git a/src/public/modules/forms/helpers/csv-response-classes/TableResponse.class.js b/src/public/modules/forms/helpers/csv-response-classes/TableResponse.class.js deleted file mode 100644 index 4352169a40..0000000000 --- a/src/public/modules/forms/helpers/csv-response-classes/TableResponse.class.js +++ /dev/null @@ -1,15 +0,0 @@ -const Response = require('./Response.class') - -module.exports = class TableResponse extends Response { - getAnswer(colIndex) { - // Leave cell empty if number of rows is fewer than the index - if (colIndex >= this._data.answerArray.length) { - return '' - } - return this._data.answerArray[colIndex].join(';') - } - - get numCols() { - return this._data.answerArray.length - } -} diff --git a/src/public/modules/forms/helpers/csv-response-classes/TableResponse.class.ts b/src/public/modules/forms/helpers/csv-response-classes/TableResponse.class.ts new file mode 100644 index 0000000000..cd62ec6b24 --- /dev/null +++ b/src/public/modules/forms/helpers/csv-response-classes/TableResponse.class.ts @@ -0,0 +1,24 @@ +import { NestedResponse } from '../../../../../types/response' + +import { Response } from './Response.class' + +export class TableResponse extends Response { + private response: NestedResponse + + constructor(responseData: NestedResponse) { + super(responseData) + this.response = responseData + } + + getAnswer(colIndex: number): string { + // Leave cell empty if number of rows is fewer than the index + if (colIndex >= this.response.answerArray.length) { + return '' + } + return this.response.answerArray[colIndex].join(';') + } + + get numCols(): number { + return this.response.answerArray.length + } +} diff --git a/src/public/modules/forms/helpers/csv-response-classes/index.js b/src/public/modules/forms/helpers/csv-response-classes/index.js deleted file mode 100644 index 837b2c4d38..0000000000 --- a/src/public/modules/forms/helpers/csv-response-classes/index.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports.SingleAnswerResponse = require('./SingleAnswerResponse.class') -module.exports.ArrayAnswerResponse = require('./ArrayAnswerResponse.class') -module.exports.TableResponse = require('./TableResponse.class') diff --git a/src/public/modules/forms/helpers/csv-response-classes/index.ts b/src/public/modules/forms/helpers/csv-response-classes/index.ts new file mode 100644 index 0000000000..b3ac7f159c --- /dev/null +++ b/src/public/modules/forms/helpers/csv-response-classes/index.ts @@ -0,0 +1,4 @@ +export { SingleAnswerResponse } from './SingleAnswerResponse.class' +export { ArrayAnswerResponse } from './ArrayAnswerResponse.class' +export { TableResponse } from './TableResponse.class' +export { Response } from './Response.class' diff --git a/src/public/modules/forms/helpers/response-factory.js b/src/public/modules/forms/helpers/response-factory.js deleted file mode 100644 index e78ad6e820..0000000000 --- a/src/public/modules/forms/helpers/response-factory.js +++ /dev/null @@ -1,18 +0,0 @@ -const { - SingleAnswerResponse, - ArrayAnswerResponse, - TableResponse, -} = require('./csv-response-classes') - -const getResponseInstance = (fieldRecordData) => { - switch (fieldRecordData.fieldType) { - case 'table': - return new TableResponse(fieldRecordData) - case 'checkbox': - return new ArrayAnswerResponse(fieldRecordData) - default: - return new SingleAnswerResponse(fieldRecordData) - } -} - -module.exports = { getResponseInstance } diff --git a/src/public/modules/forms/helpers/response-factory.ts b/src/public/modules/forms/helpers/response-factory.ts new file mode 100644 index 0000000000..3266101d50 --- /dev/null +++ b/src/public/modules/forms/helpers/response-factory.ts @@ -0,0 +1,78 @@ +import { hasProp } from '../../../../shared/util/has-prop' +import { BasicField } from '../../../../types/field' +import { + ArrayResponse, + DisplayedResponseWithoutAnswer, + NestedResponse, + SingleResponse, +} from '../../../../types/response' + +import { + ArrayAnswerResponse, + Response, + SingleAnswerResponse, + TableResponse, +} from './csv-response-classes' + +/** + * Converts a field record into a custom response instance + * @param fieldRecordData Field record + * @returns Response instance + * @throws Error when data does not fit any known response type + */ +export const getResponseInstance = ( + fieldRecordData: DisplayedResponseWithoutAnswer, +): Response => { + if ( + isNestedResponse(fieldRecordData) && + fieldRecordData.fieldType === BasicField.Table + ) { + return new TableResponse(fieldRecordData) + } else if ( + isArrayResponse(fieldRecordData) && + fieldRecordData.fieldType === BasicField.Checkbox + ) { + return new ArrayAnswerResponse(fieldRecordData) + } else if (isSingleResponse(fieldRecordData)) { + return new SingleAnswerResponse(fieldRecordData) + } else { + // eslint-disable-next-line typesafe/no-throw-sync-func + throw new Error('Response did not match any known type') + } +} + +const isNestedResponse = ( + response: DisplayedResponseWithoutAnswer, +): response is NestedResponse => { + return ( + hasAnswerArray(response) && + response.answerArray.every( + (arr) => + Array.isArray(arr) && + arr.every((value: unknown) => typeof value === 'string'), + ) + ) +} + +const isArrayResponse = ( + response: DisplayedResponseWithoutAnswer, +): response is ArrayResponse => { + return ( + hasAnswerArray(response) && + response.answerArray.every((value) => typeof value === 'string') + ) +} + +const isSingleResponse = ( + response: DisplayedResponseWithoutAnswer, +): response is SingleResponse => { + return hasProp(response, 'answer') && typeof response.answer === 'string' +} + +type AnswerArrayObject = { + answerArray: Array +} + +const hasAnswerArray = (response: unknown): response is AnswerArrayObject => { + return hasProp(response, 'answerArray') && Array.isArray(response.answerArray) +} diff --git a/src/public/modules/forms/services/submissions.client.factory.js b/src/public/modules/forms/services/submissions.client.factory.js index d0551a0a97..d04fae5570 100644 --- a/src/public/modules/forms/services/submissions.client.factory.js +++ b/src/public/modules/forms/services/submissions.client.factory.js @@ -1,6 +1,8 @@ 'use strict' -const CsvMHGenerator = require('../helpers/CsvMergedHeadersGenerator') +const { + CsvMergedHeadersGenerator, +} = require('../helpers/CsvMergedHeadersGenerator') const DecryptionWorker = require('../helpers/decryption.worker.js') const { fixParamsToUrl, triggerFileDownload } = require('../helpers/util') const { ndjsonStream } = require('../helpers/ndjsonStream') @@ -96,7 +98,7 @@ function SubmissionsFactory($q, $timeout, $window, GTag) { } let resUrl = generateDownloadUrl(params, downloadAttachments) - let experimentalCsvGenerator = new CsvMHGenerator( + let csvGenerator = new CsvMergedHeadersGenerator( expectedNumResponses, NUM_OF_METADATA_ROWS, ) @@ -136,8 +138,13 @@ function SubmissionsFactory($q, $timeout, $window, GTag) { } if (csvRecord.submissionData) { - // accumulate dataset if it exists, since we may have status columns available - experimentalCsvGenerator.addRecord(csvRecord.submissionData) + try { + // accumulate dataset if it exists, since we may have status columns available + csvGenerator.addRecord(csvRecord.submissionData) + } catch (error) { + errorCount++ + console.error('Error in getResponseInstance', error) + } } if (downloadAttachments && csvRecord.downloadBlob) { @@ -230,7 +237,7 @@ function SubmissionsFactory($q, $timeout, $window, GTag) { GTag.partialDecryptionFailure( params, numWorkers, - experimentalCsvGenerator.length(), + csvGenerator.length(), errorCount, attachmentErrorCount, timeDifference, @@ -240,7 +247,7 @@ function SubmissionsFactory($q, $timeout, $window, GTag) { new Error( JSON.stringify({ expectedCount: expectedNumResponses, - successCount: experimentalCsvGenerator.length(), + successCount: csvGenerator.length(), errorCount, unverifiedCount, }), @@ -248,18 +255,16 @@ function SubmissionsFactory($q, $timeout, $window, GTag) { ) } else if ( // All results have been decrypted - experimentalCsvGenerator.length() + - errorCount + - unverifiedCount >= + csvGenerator.length() + errorCount + unverifiedCount >= expectedNumResponses ) { killWorkers(workerPool) // Generate first three rows of meta-data before download - experimentalCsvGenerator.addMetaDataFromSubmission( + csvGenerator.addMetaDataFromSubmission( errorCount, unverifiedCount, ) - experimentalCsvGenerator.downloadCsv( + csvGenerator.downloadCsv( `${params.formTitle}-${params.formId}.csv`, ) @@ -271,18 +276,18 @@ function SubmissionsFactory($q, $timeout, $window, GTag) { GTag.downloadResponseSuccess( params, numWorkers, - experimentalCsvGenerator.length(), + csvGenerator.length(), timeDifference, ) resolve({ expectedCount: expectedNumResponses, - successCount: experimentalCsvGenerator.length(), + successCount: csvGenerator.length(), errorCount, unverifiedCount, }) // Kill class instance and reclaim the memory. - experimentalCsvGenerator = null + csvGenerator = null } else { $timeout(checkComplete, 100) } diff --git a/src/app/utils/has-prop.ts b/src/shared/util/has-prop.ts similarity index 100% rename from src/app/utils/has-prop.ts rename to src/shared/util/has-prop.ts diff --git a/src/types/response/index.ts b/src/types/response/index.ts index 06c9e6ceb6..a9b75836d1 100644 --- a/src/types/response/index.ts +++ b/src/types/response/index.ts @@ -56,3 +56,21 @@ export interface IClientEncryptSubmission extends IClientSubmission { encryptedContent: string version: number } + +export type DisplayedResponseWithoutAnswer = { + _id: string + question: string + fieldType: string + isHeader?: boolean +} + +export type ArrayResponse = DisplayedResponseWithoutAnswer & { + answerArray: string[] +} + +export type NestedResponse = DisplayedResponseWithoutAnswer & { + answerArray: string[][] +} +export type SingleResponse = DisplayedResponseWithoutAnswer & { + answer: string +} diff --git a/tests/unit/frontend/forms/helpers/CsvMergedHeadersGenerator.test.js b/tests/unit/frontend/forms/helpers/CsvMergedHeadersGenerator.test.js index ab72ebb532..11c0564a72 100644 --- a/tests/unit/frontend/forms/helpers/CsvMergedHeadersGenerator.test.js +++ b/tests/unit/frontend/forms/helpers/CsvMergedHeadersGenerator.test.js @@ -1,7 +1,7 @@ import { stringify } from 'csv-string' import moment from 'moment-timezone' - -import CsvMergedHeadersGenerator from '../../../../../src/public/modules/forms/helpers/CsvMergedHeadersGenerator' +import { getResponseInstance } from '../../../../../src/public/modules/forms/helpers/response-factory' +import { CsvMergedHeadersGenerator } from '../../../../../src/public/modules/forms/helpers/CsvMergedHeadersGenerator' const UTF8_BYTE_ORDER_MARK = '\uFEFF' const BOM_LENGTH = 1 @@ -33,9 +33,20 @@ const generateHeaderRow = (append) => { question: `mockQuestion${append}`, fieldType: `mockFieldType${append}`, isHeader: true, + answer: '', + } +} + +const generateEmptyRecord = (append) => { + return { + _id: `mock${append}`, + question: `mockQuestion${append}`, + fieldType: `mockFieldType${append}`, } } +const expectedErrorMessage = 'Response did not match any known type' + /** * Reshapes a mock record to match the expected shape in generator.unprocessed. * @param {Object} mockRecord @@ -46,7 +57,7 @@ const generateHeaderRow = (append) => { const generateExpectedUnprocessed = (mockRecord) => { const reshapedRecord = {} mockRecord.record.forEach((fieldRecord) => { - reshapedRecord[fieldRecord._id] = { _data: fieldRecord } + reshapedRecord[fieldRecord._id] = getResponseInstance(fieldRecord) }) return { created: mockRecord.created, @@ -238,6 +249,79 @@ describe('CsvMergedHeadersGenerator', () => { expectedQuestionHeader, ) }) + + it('should reject response without answer and answerArray', () => { + // Arrange + const mockDecryptedRecord = [generateEmptyRecord(1)] + const mockRecord = { + record: mockDecryptedRecord, + created: mockCreatedEarly, + submissionId: 'mockSubmissionId', + } + expect(generator.unprocessed.length).toEqual(0) + + // Act + const addRecord = () => generator.addRecord(mockRecord) + + // Assert + // Check error + expect(addRecord).toThrow(Error) + expect(addRecord).toThrow(expectedErrorMessage) + // Record should not be added + expect(generator.unprocessed.length).toEqual(0) + // Check headers + expect(generator.fieldIdToQuestion.size).toEqual(0) + }) + + it('should reject response with non-string answer', () => { + // Arrange + const invalidResponse = generateEmptyRecord(1) + invalidResponse.answer = 1 + const mockDecryptedRecord = [invalidResponse] + const mockRecord = { + record: mockDecryptedRecord, + created: mockCreatedEarly, + submissionId: 'mockSubmissionId', + } + expect(generator.unprocessed.length).toEqual(0) + + // Act + const addRecord = () => generator.addRecord(mockRecord) + + // Assert + // Check error + expect(addRecord).toThrow(Error) + expect(addRecord).toThrow(expectedErrorMessage) + // Record should not be added + expect(generator.unprocessed.length).toEqual(0) + // Check headers + expect(generator.fieldIdToQuestion.size).toEqual(0) + }) + + it('should reject response with non-string answerArray', () => { + // Arrange + const invalidResponse = generateEmptyRecord(1) + invalidResponse.answerArray = [1, 2, 3] + const mockDecryptedRecord = [invalidResponse] + const mockRecord = { + record: mockDecryptedRecord, + created: mockCreatedEarly, + submissionId: 'mockSubmissionId', + } + expect(generator.unprocessed.length).toEqual(0) + + // Act + const addRecord = () => generator.addRecord(mockRecord) + + // Assert + // Check error + expect(addRecord).toThrow(Error) + expect(addRecord).toThrow(expectedErrorMessage) + // Record should not be added + expect(generator.unprocessed.length).toEqual(0) + // Check headers + expect(generator.fieldIdToQuestion.size).toEqual(0) + }) }) describe('process()', () => {