From 38372c410874a3375d0bf5f6adf31d959e643f91 Mon Sep 17 00:00:00 2001 From: Jon Shipley Date: Thu, 2 Feb 2023 12:53:12 +0000 Subject: [PATCH] Bug/53880 user inputs (#2376) * Trim inputs during data sync * Add tests * Remove debug * Add test, remove debug, mod service * Ordered timestamps for answer, inputs and events are cohesive * add logger * Remove unused code * Remove unused code * fix lint * Cleanup and fix tests * update assets version * Remove debug * Fix flaky test * adding test for duplicate questions --------- Co-authored-by: Mohsen Qureshi Co-authored-by: Mohsen Qureshi --- docs/support-utils/util-submit-check.md | 1 + func-consumption/package.json | 2 +- func-consumption/yarn.lock | 10 +- func-ps-report/package.json | 1 - func-ps-report/yarn.lock | 5 - func-throttled/package.json | 1 - func-throttled/yarn.lock | 5 - test/pupil-hpa/features/a_ps_report.feature | 5 + .../step_definitions/ps_report_steps.rb | 42 ++- .../features/support/functions_helper.rb | 4 + tslib/package.json | 3 +- .../caching/prepared-check.service.spec.ts | 2 +- .../list-schools-service.spec.ts | 1 - .../sync-results-to-sql/models.ts | 1 + ...re-answers-and-inputs.data.service.spec.ts | 239 +++++++++++++++++- ...prepare-answers-and-inputs.data.service.ts | 102 +++++++- .../fake-check-audit-generator.service.ts | 119 --------- .../fake-check-inputs-generator.service.ts | 30 --- ...-completed-check-generator.service.spec.ts | 54 ++-- .../fake-completed-check-generator.service.ts | 187 ++++++++++---- .../fake-submitted-check-generator.service.ts | 7 + .../src/functions/util-submit-check/index.ts | 7 +- .../tests-integration/mock-payload.class.ts | 14 +- tslib/yarn.lock | 15 +- 24 files changed, 577 insertions(+), 280 deletions(-) delete mode 100644 tslib/src/functions/util-submit-check/fake-check-audit-generator.service.ts delete mode 100644 tslib/src/functions/util-submit-check/fake-check-inputs-generator.service.ts diff --git a/docs/support-utils/util-submit-check.md b/docs/support-utils/util-submit-check.md index a3987e2caa..acae5c2027 100644 --- a/docs/support-utils/util-submit-check.md +++ b/docs/support-utils/util-submit-check.md @@ -56,3 +56,4 @@ If submitting a [check-started message](../messaging/message-schemas.md) is part ## Roadmap Invalid payload generation and structures that mimic real world examples of faulty payloads seen in live check periods. + diff --git a/func-consumption/package.json b/func-consumption/package.json index d087604941..7d7da4a2d1 100644 --- a/func-consumption/package.json +++ b/func-consumption/package.json @@ -23,6 +23,7 @@ }, "devDependencies": { "@azure/functions": "^1.0.3", + "@faker-js/faker": "^7.6.0", "@types/bcryptjs": "^2.4.2", "@types/bluebird": "^3.5.27", "@types/ioredis": "^4.0.18", @@ -53,7 +54,6 @@ "bluebird": "^3.7.0", "csv-string": "^4.1.0", "dotenv": "^10.0.0", - "faker": "^5.5.3", "fast-xml-parser": "^3.20.0", "ioredis": "^4.27.9", "lz-string": "^1.4.4", diff --git a/func-consumption/yarn.lock b/func-consumption/yarn.lock index c08cfe961b..dc5b5398ec 100644 --- a/func-consumption/yarn.lock +++ b/func-consumption/yarn.lock @@ -298,6 +298,11 @@ "@azure/logger" "^1.0.0" tslib "^2.2.0" +"@faker-js/faker@^7.6.0": + version "7.6.0" + resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-7.6.0.tgz#9ea331766084288634a9247fcd8b84f16ff4ba07" + integrity sha512-XK6BTq1NDMo9Xqw/YkYyGjSsg44fbNwYRx7QK2CuoQgyy+f1rrTDHoExVM5PsyXCtfl2vs2vVJ0MN0yN6LppRw== + "@js-joda/core@^5.2.0": version "5.3.1" resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-5.3.1.tgz#998557969f357b4494649d4a63164d02645295af" @@ -1429,11 +1434,6 @@ extglob@^2.0.4: snapdragon "^0.8.1" to-regex "^3.0.1" -faker@^5.5.3: - version "5.5.3" - resolved "https://registry.yarnpkg.com/faker/-/faker-5.5.3.tgz#c57974ee484431b25205c2c8dc09fda861e51e0e" - integrity sha512-wLTv2a28wjUyWkbnX7u/ABZBkUkIF2fCd73V6P2oFqEGEktDfzWx4UxrSqtPRw0xPRAcjeAOIiJWqZm3pP4u3g== - fancy-log@^1.3.2: version "1.3.3" resolved "https://registry.yarnpkg.com/fancy-log/-/fancy-log-1.3.3.tgz#dbc19154f558690150a23953a0adbd035be45fc7" diff --git a/func-ps-report/package.json b/func-ps-report/package.json index 879d75989f..b734269b36 100644 --- a/func-ps-report/package.json +++ b/func-ps-report/package.json @@ -43,7 +43,6 @@ "bluebird": "^3.7.0", "csv-string": "^4.1.0", "dotenv": "^10.0.0", - "faker": "^5.5.3", "fast-xml-parser": "^3.20.0", "ioredis": "^4.27.9", "lz-string": "^1.4.4", diff --git a/func-ps-report/yarn.lock b/func-ps-report/yarn.lock index bdce5b4b82..4361ddfde1 100644 --- a/func-ps-report/yarn.lock +++ b/func-ps-report/yarn.lock @@ -1364,11 +1364,6 @@ extglob@^2.0.4: snapdragon "^0.8.1" to-regex "^3.0.1" -faker@^5.5.3: - version "5.5.3" - resolved "https://registry.yarnpkg.com/faker/-/faker-5.5.3.tgz#c57974ee484431b25205c2c8dc09fda861e51e0e" - integrity sha512-wLTv2a28wjUyWkbnX7u/ABZBkUkIF2fCd73V6P2oFqEGEktDfzWx4UxrSqtPRw0xPRAcjeAOIiJWqZm3pP4u3g== - fancy-log@^1.3.2: version "1.3.3" resolved "https://registry.yarnpkg.com/fancy-log/-/fancy-log-1.3.3.tgz#dbc19154f558690150a23953a0adbd035be45fc7" diff --git a/func-throttled/package.json b/func-throttled/package.json index dd66136aa9..a0baf523b8 100644 --- a/func-throttled/package.json +++ b/func-throttled/package.json @@ -43,7 +43,6 @@ "bluebird": "^3.7.0", "csv-string": "^4.1.0", "dotenv": "^10.0.0", - "faker": "^5.5.3", "fast-xml-parser": "^3.20.0", "ioredis": "^4.27.9", "lz-string": "^1.4.4", diff --git a/func-throttled/yarn.lock b/func-throttled/yarn.lock index bdce5b4b82..4361ddfde1 100644 --- a/func-throttled/yarn.lock +++ b/func-throttled/yarn.lock @@ -1364,11 +1364,6 @@ extglob@^2.0.4: snapdragon "^0.8.1" to-regex "^3.0.1" -faker@^5.5.3: - version "5.5.3" - resolved "https://registry.yarnpkg.com/faker/-/faker-5.5.3.tgz#c57974ee484431b25205c2c8dc09fda861e51e0e" - integrity sha512-wLTv2a28wjUyWkbnX7u/ABZBkUkIF2fCd73V6P2oFqEGEktDfzWx4UxrSqtPRw0xPRAcjeAOIiJWqZm3pP4u3g== - fancy-log@^1.3.2: version "1.3.3" resolved "https://registry.yarnpkg.com/fancy-log/-/fancy-log-1.3.3.tgz#dbc19154f558690150a23953a0adbd035be45fc7" diff --git a/test/pupil-hpa/features/a_ps_report.feature b/test/pupil-hpa/features/a_ps_report.feature index 60805497e1..ef198a7706 100644 --- a/test/pupil-hpa/features/a_ps_report.feature +++ b/test/pupil-hpa/features/a_ps_report.feature @@ -83,3 +83,8 @@ Feature: When I generate a new pin and complete the check And the data sync and ps report function has run Then I should see the restart reason in the ps report record + + Scenario: Only first input used when a duplicate question is shown + Given I have completed a check with duplicate questions + And the data sync and ps report function has run + Then I should see the ps report showing the first input diff --git a/test/pupil-hpa/features/step_definitions/ps_report_steps.rb b/test/pupil-hpa/features/step_definitions/ps_report_steps.rb index 526f07995d..3532b54194 100644 --- a/test/pupil-hpa/features/step_definitions/ps_report_steps.rb +++ b/test/pupil-hpa/features/step_definitions/ps_report_steps.rb @@ -235,17 +235,14 @@ p @check_code end - Then(/^the ps report record should be updated with all the check details$/) do step 'I should see a record for the pupil in the ps report table' end - And(/^the latest check is recorded$/) do expect(@check_details['checkCode']).eql? @second_check_code end - When(/^I add an AA arrangement$/) do access_arrangments_type = "Audible time alert" visit ENV["ADMIN_BASE_URL"] + access_arrangements_page.url @@ -281,7 +278,6 @@ p @check_code end - Then(/^the PS report should include the AA for the pupil$/) do step 'the data sync and ps report function has run' step 'I should see a record for the pupil in the ps report table' @@ -290,7 +286,6 @@ expect(pupil_aa).to eql '[1]' end - Given(/^I generated a pin after applying a restart$/) do step "I have completed the check" step 'I login to the admin app' @@ -315,7 +310,6 @@ SqlDbHelper.delete_check_pin(@check_details["id"]) end - When(/^I generate a new pin and complete the check$/) do navigate_to_pupil_list_for_pin_gen('live') generate_pins_overview_page.generate_pin_using_name(@details_hash[:last_name] + ', ' + @details_hash[:first_name]) @@ -345,24 +339,20 @@ p @check_code end - Then(/^I should see the restart reason in the ps report record$/) do ps_report_record = SqlDbHelper.get_ps_record_for_pupil(@pupil_details['id']) expect(ps_report_record['RestartReason']).to eql 2 end - Given(/^I have completed the check for a pupil attending a test school$/) do SqlDbHelper.set_school_as_test_school(@school['entity']['dfeNumber']) step 'I have completed the check' end - Then(/^I should not see any records for the test school$/) do expect(SqlDbHelper.count_all_ps_records_for_school(@school_id)).to eql 0 end - When(/^the data sync and ps report function has run for the test school$/) do step 'the data sync function has run' sleep ENV['PS_REPORT_WAIT_TIME'].to_i @@ -371,3 +361,35 @@ expect(response.code).to eql 202 sleep ENV['PS_REPORT_WAIT_TIME'].to_i end + +Given(/^I have completed a check with duplicate questions$/) do + name = (0...8).map {(65 + rand(26)).chr}.join + step 'I add a pupil' + step 'I login to the admin app' + navigate_to_pupil_list_for_pin_gen('live') + generate_pins_overview_page.generate_pin_using_name(@details_hash[:last_name] + ', ' + @details_hash[:first_name]) + pupil_pin_row = view_and_custom_print_live_check_page.pupil_list.rows.find {|row| row.name.text == @details_hash[:last_name] + ', ' + @details_hash[:first_name]} + @pupil_credentials = {:school_password => pupil_pin_row.school_password.text, :pin => pupil_pin_row.pin.text} + p @pupil_credentials + AzureTableHelper.wait_for_prepared_check(@pupil_credentials[:school_password], @pupil_credentials[:pin]) + @check_code = SqlDbHelper.check_details(@stored_pupil_details['id'])['checkCode'] + @pupil_id = @stored_pupil_details['id'] + check_entry = SqlDbHelper.check_details(@pupil_id) + Timeout.timeout(ENV['WAIT_TIME'].to_i) {sleep 1 until RequestHelper.auth(@pupil_credentials[:school_password], @pupil_credentials[:pin]).code == 200} + RequestHelper.auth(@pupil_credentials[:school_password], @pupil_credentials[:pin]) + @check_code = check_entry['checkCode'] + FunctionsHelper.complete_check_with_duplicates([@check_code], 25, 0, rand(25)) if check_entry["isLiveCheck"] + @recieved_check = AzureTableHelper.wait_for_received_check(@school['entity']['urlSlug'], @check_code) if check_entry["isLiveCheck"] + p @check_code +end + +Then(/^I should see the ps report showing the first input$/) do + @answers = JSON.parse(LZString::UTF16.decompress(@recieved_check['archive']))['answers'] + grouped = @answers.group_by {|row| [row['sequenceNumber'], row['question']]} + duplicates = grouped.values.select {|a| a.size > 1} + expected_answers = duplicates.map {|d| d.first['answer']} + expected_questions = duplicates.map {|d| d.first['sequenceNumber'].to_s} + ps_report_record = SqlDbHelper.get_ps_record_for_pupil(@pupil_id) + answers = expected_questions.map {|question| ps_report_record["Q#{question}Response"]} + expect(expected_answers).to eql answers +end diff --git a/test/pupil-hpa/features/support/functions_helper.rb b/test/pupil-hpa/features/support/functions_helper.rb index 578d8c6669..0ec34e6818 100644 --- a/test/pupil-hpa/features/support/functions_helper.rb +++ b/test/pupil-hpa/features/support/functions_helper.rb @@ -25,4 +25,8 @@ def self.complete_check_via_check_code(check_code_array) HTTParty.post(ENV['FUNC_CONSUMP_BASE_URL'] + "/api/util-submit-check", :body => {'checkCodes': check_code_array}.to_json, headers: {'Content-Type' => 'application/json', 'x-functions-key' => ENV['FUNC_CONSUMP_MASTER_KEY']}) end + def self.complete_check_with_duplicates(check_code_array, correct_answers,incorrect_answers,duplicate_answers) + HTTParty.post(ENV['FUNC_CONSUMP_BASE_URL'] + "/api/util-submit-check", :body => {'checkCodes': check_code_array, 'answerNumberFromCorrectCheckForm': correct_answers, 'answerNumberFromIncorrectCheckForm': incorrect_answers, 'answerNumberOfDuplicates': duplicate_answers}.to_json, headers: {'Content-Type' => 'application/json', 'x-functions-key' => ENV['FUNC_CONSUMP_MASTER_KEY']}) + end + end diff --git a/tslib/package.json b/tslib/package.json index 0ab7e163bc..abc98e323f 100644 --- a/tslib/package.json +++ b/tslib/package.json @@ -29,10 +29,10 @@ }, "devDependencies": { "@azure/functions": "^1.2.3", + "@faker-js/faker": "^7.6.0", "@types/adm-zip": "^0.4.34", "@types/async": "^3.2.7", "@types/bluebird": "^3.5.36", - "@types/faker": "^5.5.8", "@types/he": "^1.1.2", "@types/ioredis": "^4.27.4", "@types/jest": "^26.0.15", @@ -70,7 +70,6 @@ "bluebird": "^3.7.0", "csv-string": "^4.1.0", "dotenv": "^10.0.0", - "faker": "^5.5.3", "fast-xml-parser": "^3.20.0", "ioredis": "^4.27.9", "lz-string": "^1.4.4", diff --git a/tslib/src/caching/prepared-check.service.spec.ts b/tslib/src/caching/prepared-check.service.spec.ts index 25575c5aaa..82ffa5199f 100644 --- a/tslib/src/caching/prepared-check.service.spec.ts +++ b/tslib/src/caching/prepared-check.service.spec.ts @@ -1,6 +1,6 @@ import { IRedisService } from './redis-service' import { RedisServiceMock } from './redis-service.mock' -import * as faker from 'faker' +import { faker } from '@faker-js/faker' import redisKeyService from './redis-key.service' import { PreparedCheckService } from './prepared-check.service' diff --git a/tslib/src/functions-ps-report/ps-report-1-list-schools/list-schools-service.spec.ts b/tslib/src/functions-ps-report/ps-report-1-list-schools/list-schools-service.spec.ts index 682560e5a9..1f93404bac 100644 --- a/tslib/src/functions-ps-report/ps-report-1-list-schools/list-schools-service.spec.ts +++ b/tslib/src/functions-ps-report/ps-report-1-list-schools/list-schools-service.spec.ts @@ -28,7 +28,6 @@ describe('ListSchoolsService', () => { test('getSchoolMessages returns messages', async () => { const resp = await sut.getSchoolMessages() - console.log(resp) expect(resp).toHaveLength(2) expect(resp[0].name).toBe('School One') expect(resp[0].uuid).toBe('uuid1') diff --git a/tslib/src/functions-throttled/sync-results-to-sql/models.ts b/tslib/src/functions-throttled/sync-results-to-sql/models.ts index c630a8f3e3..e08d4e1caf 100644 --- a/tslib/src/functions-throttled/sync-results-to-sql/models.ts +++ b/tslib/src/functions-throttled/sync-results-to-sql/models.ts @@ -44,6 +44,7 @@ export interface Audit { type: string clientTimestamp: string data?: Data + monotonicTime?: IMonotonicTimeDto } export interface Data { diff --git a/tslib/src/functions-throttled/sync-results-to-sql/prepare-answers-and-inputs.data.service.spec.ts b/tslib/src/functions-throttled/sync-results-to-sql/prepare-answers-and-inputs.data.service.spec.ts index a2730add37..f13d492a35 100644 --- a/tslib/src/functions-throttled/sync-results-to-sql/prepare-answers-and-inputs.data.service.spec.ts +++ b/tslib/src/functions-throttled/sync-results-to-sql/prepare-answers-and-inputs.data.service.spec.ts @@ -2,8 +2,8 @@ import { IPrepareAnswersAndInputsDataService, PrepareAnswersAndInputsDataService import { mockCompletionCheckMessage } from './mocks/completed-check.message' import { IQuestionService, QuestionService } from './question.service' import { IUserInputService, UserInputService } from './user-input.service' -import { ISqlService } from '../../sql/sql.service' -import { DBQuestion } from './models' +import { ISqlService, ITransactionRequest } from '../../sql/sql.service' +import { Answer, DBQuestion, Input, MarkedAnswer, ValidatedCheck } from './models' const mockQuestion: DBQuestion = { id: 1, @@ -13,11 +13,16 @@ const mockQuestion: DBQuestion = { code: 'Q001' } -let sut: IPrepareAnswersAndInputsDataService +let sut: ITestPrepareAnswersAndInputsDataService let questionService: IQuestionService let userInputService: IUserInputService let mockSqlService: ISqlService +interface ITestPrepareAnswersAndInputsDataService extends IPrepareAnswersAndInputsDataService { + questionWasAnsweredMoreThanOnce (answers: Answer[], markedAnswer: MarkedAnswer): boolean + prepareInputs (rawInputs: Input[], question: DBQuestion, sqlAnswerVar: string): Promise +} + describe('PrepareAnswersAndInputsDataService', () => { beforeEach(() => { mockSqlService = { @@ -35,7 +40,7 @@ describe('PrepareAnswersAndInputsDataService', () => { { id: 4, name: 'Pen', code: 'P' }, { id: 5, name: 'Unknown', code: 'X' } ]) - sut = new PrepareAnswersAndInputsDataService(questionService, userInputService) + sut = new PrepareAnswersAndInputsDataService(questionService, userInputService) as unknown as ITestPrepareAnswersAndInputsDataService }) test('it is defined', () => { @@ -67,4 +72,230 @@ describe('PrepareAnswersAndInputsDataService', () => { await sut.prepareAnswersAndInputs(mockCompletionCheckMessage.markedCheck, mockCompletionCheckMessage.validatedCheck) expect(prepareInputSpy).toHaveBeenCalledTimes(10) }) + + test('it can detect when a question was answered more than once', async () => { + const spy = jest.spyOn(sut, 'questionWasAnsweredMoreThanOnce') + jest.spyOn(questionService, 'findQuestion').mockResolvedValue(mockQuestion) + const validatedCheck: ValidatedCheck = JSON.parse(JSON.stringify(mockCompletionCheckMessage.validatedCheck)) + // Add a second answered question to the validated check structure + validatedCheck.answers.push({ + factor1: 6, + factor2: 5, + answer: '99', + sequenceNumber: 9, + question: '6x5', + clientTimestamp: '2020-09-29T12:26:36.345Z' + }) + await sut.prepareAnswersAndInputs(mockCompletionCheckMessage.markedCheck, validatedCheck) + + // We expect the 9th question to have been asked more than once (as there is a second entry in validatedCheck.answers). There + // isn't a corresponding second entry in markedCheck.answers because it only marks the first question. + expect(spy.mock.calls[8][1].sequenceNumber).toBe(9) // check we have the 9th answer in the index 8 + expect(spy.mock.results[8].value).toBe(true) + + // All the other questions should not have been duplicates + expect(spy.mock.results[0].value).toBe(false) + expect(spy.mock.results[1].value).toBe(false) + expect(spy.mock.results[2].value).toBe(false) + expect(spy.mock.results[3].value).toBe(false) + expect(spy.mock.results[4].value).toBe(false) + expect(spy.mock.results[5].value).toBe(false) + expect(spy.mock.results[6].value).toBe(false) + expect(spy.mock.results[7].value).toBe(false) + expect(spy.mock.results[9].value).toBe(false) + }) + + test('it removes inputs from duplicate answers, so that only the input from the marked answer is used', async () => { + jest.spyOn(questionService, 'findQuestion').mockResolvedValue(mockQuestion) + const prepareInputsSpy = jest.spyOn(sut, 'prepareInputs') + const validatedCheck: ValidatedCheck = JSON.parse(JSON.stringify(mockCompletionCheckMessage.validatedCheck)) + // Add a second answered question to the validated check structure + validatedCheck.answers.push({ + factor1: 6, + factor2: 5, + answer: '99', + sequenceNumber: 9, + question: '6x5', + clientTimestamp: '2020-09-29T12:26:36.345Z' + }) + // Add the inputs for the duplicate answer. The first question was already answered at 2020-09-29T12:26:27 + validatedCheck.inputs.push({ + input: '9', + eventType: 'keydown', + clientTimestamp: '2020-09-29T12:26:36.300Z', + question: '6x5', + sequenceNumber: 9 + }) + validatedCheck.inputs.push({ + input: '9', + eventType: 'keydown', + clientTimestamp: '2020-09-29T12:26:36.310Z', + question: '6x5', + sequenceNumber: 9 + }) + validatedCheck.inputs.push({ + input: 'Enter', + eventType: 'keydown', + clientTimestamp: '2020-09-29T12:26:36.344Z', + question: '6x5', + sequenceNumber: 9 + }) + // Add events for the duplicate answer + validatedCheck.audit.push( + { + type: 'PauseRendered', + clientTimestamp: '2020-09-29T12:26:30.000Z', + data: { + sequenceNumber: 9, + question: '6x5' + } + }, + { + type: 'QuestionTimerStarted', + clientTimestamp: '2020-09-29T12:26:30.000Z', + data: { + sequenceNumber: 9, + question: '6x5' + } + }, + { + type: 'QuestionRendered', + clientTimestamp: '2020-09-29T12:26:33.000Z', + data: { + sequenceNumber: 9, + question: '6x5' + } + }, + { + type: 'QuestionTimerCancelled', + clientTimestamp: '2020-09-29T12:26:36.344Z', + data: { + sequenceNumber: 9, + question: '6x5' + } + }, + { + type: 'QuestionAnswered', + clientTimestamp: '2020-09-29T12:26:36.345Z', + data: { + sequenceNumber: 9, + question: '6x5' + } + } + ) + + await sut.prepareAnswersAndInputs(mockCompletionCheckMessage.markedCheck, validatedCheck) + // We expect only the first inputs, and those we added in above. + expect(prepareInputsSpy.mock.calls[8][0]).toStrictEqual([{ + input: '3', + eventType: 'keydown', + clientTimestamp: '2020-09-29T12:26:27.414Z', + question: '6x5', + sequenceNumber: 9 + }, + { + input: '0', + eventType: 'keydown', + clientTimestamp: '2020-09-29T12:26:27.591Z', + question: '6x5', + sequenceNumber: 9 + }, + { + input: 'Enter', + eventType: 'keydown', + clientTimestamp: '2020-09-29T12:26:27.819Z', + question: '6x5', + sequenceNumber: 9 + }]) + }) + + test('it removes inputs from duplicate answers when the first answer has no inputs (left blank)', async () => { + jest.spyOn(questionService, 'findQuestion').mockResolvedValue(mockQuestion) + const prepareInputsSpy = jest.spyOn(sut, 'prepareInputs') + const validatedCheck: ValidatedCheck = JSON.parse(JSON.stringify(mockCompletionCheckMessage.validatedCheck)) + validatedCheck.answers.forEach(ans => { + if (ans.sequenceNumber === 9) { + ans.answer = '' // blank answer for Q9 + } + }) + // We need to remove all inputs from the validatedCheck for our blank answer + validatedCheck.inputs = validatedCheck.inputs.filter(inp => inp.sequenceNumber !== 9) + // Add a second attempt at Q9 to the end of the questions + validatedCheck.answers.push({ + factor1: 6, + factor2: 5, + answer: '99', + sequenceNumber: 9, + question: '6x5', + clientTimestamp: '2020-09-29T12:26:36.345Z' + }) + // Add the inputs for the duplicate answer. The first question was already answered at 2020-09-29T12:26:27 + validatedCheck.inputs.push({ + input: '9', + eventType: 'keydown', + clientTimestamp: '2020-09-29T12:26:36.300Z', + question: '6x5', + sequenceNumber: 9 + }) + validatedCheck.inputs.push({ + input: '9', + eventType: 'keydown', + clientTimestamp: '2020-09-29T12:26:36.310Z', + question: '6x5', + sequenceNumber: 9 + }) + validatedCheck.inputs.push({ + input: 'Enter', + eventType: 'keydown', + clientTimestamp: '2020-09-29T12:26:36.344Z', + question: '6x5', + sequenceNumber: 9 + }) + // Add events for the duplicate answer + validatedCheck.audit.push( + { + type: 'PauseRendered', + clientTimestamp: '2020-09-29T12:26:30.000Z', + data: { + sequenceNumber: 9, + question: '6x5' + } + }, + { + type: 'QuestionTimerStarted', + clientTimestamp: '2020-09-29T12:26:30.000Z', + data: { + sequenceNumber: 9, + question: '6x5' + } + }, + { + type: 'QuestionRendered', + clientTimestamp: '2020-09-29T12:26:33.000Z', + data: { + sequenceNumber: 9, + question: '6x5' + } + }, + { + type: 'QuestionTimerCancelled', + clientTimestamp: '2020-09-29T12:26:36.344Z', + data: { + sequenceNumber: 9, + question: '6x5' + } + }, + { + type: 'QuestionAnswered', + clientTimestamp: '2020-09-29T12:26:36.345Z', + data: { + sequenceNumber: 9, + question: '6x5' + } + } + ) + await sut.prepareAnswersAndInputs(mockCompletionCheckMessage.markedCheck, validatedCheck) + // There should not be any inputs for Q9 + expect(prepareInputsSpy.mock.calls[8][0]).toStrictEqual([]) + }) }) diff --git a/tslib/src/functions-throttled/sync-results-to-sql/prepare-answers-and-inputs.data.service.ts b/tslib/src/functions-throttled/sync-results-to-sql/prepare-answers-and-inputs.data.service.ts index 9bb101b292..ba9d5e5df3 100644 --- a/tslib/src/functions-throttled/sync-results-to-sql/prepare-answers-and-inputs.data.service.ts +++ b/tslib/src/functions-throttled/sync-results-to-sql/prepare-answers-and-inputs.data.service.ts @@ -1,10 +1,11 @@ -import { DBQuestion, Input, MarkedAnswer, MarkedCheck, ValidatedCheck } from './models' +import { DBQuestion, Input, MarkedAnswer, MarkedCheck, ValidatedCheck, Answer, Audit } from './models' import { ISqlParameter, ITransactionRequest } from '../../sql/sql.service' import * as R from 'ramda' import { TYPES } from 'mssql' import { IQuestionService, QuestionService } from './question.service' import { IUserInputService, UserInputService } from './user-input.service' import { payloadSort } from '../../services/payload-sort' +import moment from 'moment' export interface UserInputTypeLookup { id: number @@ -25,6 +26,73 @@ export class PrepareAnswersAndInputsDataService { this.userInputService = userInputService ?? new UserInputService() } + private questionWasAnsweredMoreThanOnce (answers: Answer[], markedAnswer: MarkedAnswer): boolean { + const isMatch = (answer: Answer): boolean => answer.sequenceNumber === markedAnswer.sequenceNumber && + answer.factor1 === markedAnswer.factor1 && + answer.factor2 === markedAnswer.factor2 + const count = R.count(isMatch, answers) + return count > 1 + } + + private sortEvents (events: Audit[]): Audit[] { + const comparator = (a: Audit, b: Audit): number => { + const aDate = new Date(a.clientTimestamp) + const bDate = new Date(b.clientTimestamp) + if (aDate < bDate) { + return -1 + } else if (aDate.getTime() === bDate.getTime()) { + if (a?.data?.monotonicTime?.sequenceNumber !== undefined && b?.data?.monotonicTime?.sequenceNumber !== undefined) { + return a?.data?.monotonicTime?.sequenceNumber - b?.data?.monotonicTime?.sequenceNumber + } else { + return 0 + } + } + return 1 + } + // Return a new sorted array, no mutation + return R.sort(comparator, events) + } + + private findQuestionEventsByType (searchType: string | string[], markedAnswer: MarkedAnswer, audits: Audit[]): Audit[] { + const matches = [] + if (typeof searchType === 'string') { + searchType = [searchType] + } + + for (const audit of audits) { + // Eliminate audits with the wrong type + for (const stype of searchType) { + if (audit.type === stype) { + if (audit.data?.sequenceNumber === markedAnswer.sequenceNumber && + audit.data?.question === markedAnswer.question) { + matches.push(audit) + } + } + } + } + return matches + } + + private getTimerStarted (audits: Audit[], markedAnswer: MarkedAnswer): moment.Moment | null { + const matches = this.findQuestionEventsByType('QuestionTimerStarted', markedAnswer, audits) + // We only accept the first answer, so this will correspond to the first event. The validated check may not be sorted. + const sortedMatches = this.sortEvents(matches) + if (sortedMatches.length === 0) { + return null + } + return moment(sortedMatches[0].clientTimestamp) + } + + private getTimerFinished (audits: Audit[], markedAnswer: MarkedAnswer): moment.Moment | null { + const timerFinishedMatches = this.findQuestionEventsByType(['QuestionTimerEnded', 'QuestionTimerCancelled'], markedAnswer, audits) + // We only accept the first answer, so this will correspond to the first event. The validated check may not be sorted. + const sortedMatches = this.sortEvents(timerFinishedMatches) + if (sortedMatches.length === 0) { + return null + } + return moment(sortedMatches[0].clientTimestamp) + } + /** * Generate SQL statements to insert the inputs captured during the check to the DB. * @param {ValidatedCheck} validatedCheck @@ -81,6 +149,12 @@ export class PrepareAnswersAndInputsDataService { sqls.push(sqlHead) params.push(headParam) + /** + * NB + * + * markedAnswers are by definition only going to be the 25 questions that were marked. The inputs are still in raw form, and could potentially include + * inputs from a re-played question, which can happen somehow for a very small number of checks. + */ for (const markedAnswer of markedAnswers) { let question: DBQuestion try { @@ -101,7 +175,31 @@ export class PrepareAnswersAndInputsDataService { { name: `answerQuestionId${suffix}`, value: question.id, type: TYPES.Int }, { name: `answerIsCorrect${suffix}`, value: markedAnswer.isCorrect, type: TYPES.Bit }, { name: `answerBrowserTimestamp${suffix}`, value: markedAnswer.clientTimestamp, type: TYPES.DateTimeOffset }) - const inputsForThisQuestion = rawInputs.filter(o => o.question === markedAnswer.question && o.sequenceNumber === markedAnswer.sequenceNumber) + + /** + * NB + * + * We need to filter the inputs in the case of duplicate questions as the raw inputs will include the inputs from more than one question. + * + */ + let inputsForThisQuestion = rawInputs.filter(o => o.question === markedAnswer.question && o.sequenceNumber === markedAnswer.sequenceNumber) + const tmpInputs: Input[] = [] + + // Only filter the inputs if we have a duplicate question to minimise any adverse filtering. + if (this.questionWasAnsweredMoreThanOnce(validatedCheck.answers, markedAnswer)) { + const timerStarted = this.getTimerStarted(validatedCheck.audit, markedAnswer) + const timerEnded = this.getTimerFinished(validatedCheck.audit, markedAnswer) + if (timerStarted !== null && timerEnded !== null) { + inputsForThisQuestion.forEach(input => { + const inputTime = moment(input.clientTimestamp) + if (inputTime.isBetween(timerStarted, timerEnded, undefined, '[]')) { // inclusive of the moment timestamps + tmpInputs.push(input) + } + }) + inputsForThisQuestion = tmpInputs + } + } + const { sql: inputSql, params: inputParams } = await this.prepareInputs(inputsForThisQuestion, question, `@answerId${suffix}`) sqls.push(inputSql) diff --git a/tslib/src/functions/util-submit-check/fake-check-audit-generator.service.ts b/tslib/src/functions/util-submit-check/fake-check-audit-generator.service.ts deleted file mode 100644 index 8b8a2d1509..0000000000 --- a/tslib/src/functions/util-submit-check/fake-check-audit-generator.service.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { CheckQuestion, CompleteCheckAuditEntry, AuditEntryType } from '../../schemas/check-schemas/validated-check' -import moment from 'moment' - -export class FakeCheckAuditGeneratorService { - createAudits (questions: CheckQuestion[]): CompleteCheckAuditEntry[] { - return [...this.buildWarmupEntries(), ...this.buildQuestionEntries(questions), ...this.buildSubmissionEntries()] - } - - private buildSubmissionEntries (): CompleteCheckAuditEntry[] { - return [ - { - type: AuditEntryType.CheckSubmissionPending, - clientTimestamp: moment().toISOString() - }, - { - type: AuditEntryType.CheckSubmissionApiCalled, - clientTimestamp: moment().toISOString() - } - ] - } - - private buildQuestionEntries (questions: CheckQuestion[]): CompleteCheckAuditEntry[] { - const questionAudit = new Array() - const typeEntries = [AuditEntryType.PauseRendered, AuditEntryType.QuestionRendered, - AuditEntryType.QuestionTimerStarted, AuditEntryType.QuestionAnswered, AuditEntryType.QuestionTimerCancelled] - for (let index = 0; index < questions.length; index++) { - const question = questions[index] - const data = { - sequenceNumber: question.order, - question: `${question.factor1}x${question.factor2}`, - isWarmup: false - } - const questionEntries = typeEntries.map(t => { - return { - clientTimestamp: moment().toISOString(), - relativeTiming: '+0', - type: t, - data: data - } - }) - questionAudit.push(...questionEntries) - } - return questionAudit - } - - private readonly warmupQuestions: CheckQuestion[] = [ - { - factor1: 1, - factor2: 5, - order: 1 - }, - { - factor1: 3, - factor2: 3, - order: 2 - }, - { - factor1: 5, - factor2: 5, - order: 3 - } - ] - - private buildWarmupEntries (): CompleteCheckAuditEntry[] { - const header: CompleteCheckAuditEntry[] = [{ - type: AuditEntryType.WarmupStarted, - clientTimestamp: moment().add(1, 'seconds').toISOString() - }, - { - type: AuditEntryType.WarmupIntroRendered, - clientTimestamp: moment().add(2, 'seconds').toISOString() - }] - const warmupAnswers = new Array() - for (let index = 0; index < this.warmupQuestions.length; index++) { - const q = this.warmupQuestions[index] - warmupAnswers.push(...this.buildWarmupQuestionEntries(q)) - } - const footer: CompleteCheckAuditEntry = { - type: AuditEntryType.WarmupCompleteRendered, - clientTimestamp: moment().toISOString() - } - return [...header, ...warmupAnswers, footer] - } - - private buildWarmupQuestionEntries (question: CheckQuestion): CompleteCheckAuditEntry[] { - const data = { - sequenceNumber: question.order, - question: `${question.factor1}x${question.factor2}`, - isWarmup: true - } - return [ - { - clientTimestamp: moment().toISOString(), - type: AuditEntryType.PauseRendered, - data: data - }, - { - clientTimestamp: moment().toISOString(), - type: AuditEntryType.QuestionTimerStarted, - data: data - }, - { - clientTimestamp: moment().toISOString(), - type: AuditEntryType.QuestionRendered, - data: data - }, - { - clientTimestamp: moment().toISOString(), - type: AuditEntryType.QuestionTimerCancelled, - data: data - }, - { - clientTimestamp: moment().toISOString(), - type: AuditEntryType.QuestionAnswered, - data: data - } - ] - } -} diff --git a/tslib/src/functions/util-submit-check/fake-check-inputs-generator.service.ts b/tslib/src/functions/util-submit-check/fake-check-inputs-generator.service.ts deleted file mode 100644 index 4843cf751c..0000000000 --- a/tslib/src/functions/util-submit-check/fake-check-inputs-generator.service.ts +++ /dev/null @@ -1,30 +0,0 @@ -import moment from 'moment' -import { CompleteCheckAnswer, CompleteCheckInputEntry, InputEventType } from '../../schemas/check-schemas/validated-check' - -export class FakeCheckInputsGeneratorService { - create (answers: CompleteCheckAnswer[]): CompleteCheckInputEntry[] { - const inputs = new Array() - for (let index = 0; index < answers.length; index++) { - const answer = answers[index] - const answerString = answer.answer.toString() - for (let index = 0; index < answerString.length; index++) { - const char = answerString.charAt(index) - inputs.push({ - input: char, - clientTimestamp: moment().toISOString(), - eventType: InputEventType.Keyboard, - question: `${answer.factor1}x${answer.factor2}`, - sequenceNumber: answer.sequenceNumber - }) - } - inputs.push({ - input: 'Enter', - clientTimestamp: moment().toISOString(), - eventType: InputEventType.Keyboard, - question: `${answer.factor1}x${answer.factor2}`, - sequenceNumber: answer.sequenceNumber - }) - } - return inputs - } -} diff --git a/tslib/src/functions/util-submit-check/fake-completed-check-generator.service.spec.ts b/tslib/src/functions/util-submit-check/fake-completed-check-generator.service.spec.ts index 38bac1b7e8..52c4699c4c 100644 --- a/tslib/src/functions/util-submit-check/fake-completed-check-generator.service.spec.ts +++ b/tslib/src/functions/util-submit-check/fake-completed-check-generator.service.spec.ts @@ -1,15 +1,9 @@ -import { FakeCompletedCheckGeneratorService, ICompletedCheckGeneratorService } from './fake-completed-check-generator.service' +import { FakeCompletedCheckGeneratorService } from './fake-completed-check-generator.service' import mockPreparedCheck from '../../schemas/check-schemas/mock-prepared-check-2021.json' import { CheckQuestion, CompleteCheckAnswer } from '../../schemas/check-schemas/validated-check' let sut: FakeCompletedCheckGeneratorService -class TestFakeCompletedCheckGeneratorService extends FakeCompletedCheckGeneratorService implements ICompletedCheckGeneratorService { - public testCreateAnswers (questions: CheckQuestion[], numberFromCorrectCheckForm: number = questions.length, numberFromIncorrectCheckForm: number = 0): CompleteCheckAnswer[] { - return this.createAnswers(questions, numberFromCorrectCheckForm, numberFromIncorrectCheckForm) - } -} - describe('submitted-check-generator-service', () => { beforeEach(() => { sut = new FakeCompletedCheckGeneratorService() @@ -39,24 +33,44 @@ describe('submitted-check-generator-service', () => { }) test('answer count from the correct form should equal the amount requested', () => { - const tsut = new TestFakeCompletedCheckGeneratorService() - const answers = tsut.testCreateAnswers(mockPreparedCheck.questions, 7) // the mock has 10 questions - expect(answers).toHaveLength(7) + const responses = sut.createResponses(mockPreparedCheck.questions, 7) // the mock has 10 questions + expect(responses.answers).toHaveLength(7) }) test('answer count from the incorrect forms should equal the amount requested', () => { - const tsut = new TestFakeCompletedCheckGeneratorService() - const answers = tsut.testCreateAnswers(mockPreparedCheck.questions, 0, 6) // the mock has 10 questions - expect(answers).toHaveLength(6) + const responses = sut.createResponses(mockPreparedCheck.questions, 0, 6) // the mock has 10 questions + expect(responses.answers).toHaveLength(6) }) test('answer count with mixed correct and incorrect forms should equal the amount requested', () => { - const tsut = new TestFakeCompletedCheckGeneratorService() - const answers = tsut.testCreateAnswers(mockPreparedCheck.questions, 5, 7) // the mock has 10 questions - expect(answers).toHaveLength(12) - // In this prepared unit test all the non-form questions have an obvious characteristic: the first factor is - // zero. - expect(answers.filter(a => a.factor1 !== 0)).toHaveLength(5) - expect(answers.filter(a => a.factor1 === 0)).toHaveLength(7) + const response = sut.createResponses(mockPreparedCheck.questions, 5, 7) // the mock has 10 questions + expect(response.answers).toHaveLength(12) + const correctCheckFormAnswers = filterValidAnswers(mockPreparedCheck.questions, response.answers) + expect(correctCheckFormAnswers).toHaveLength(5) + const invalidCheckFormAnswers = filterInValidAnswers(mockPreparedCheck.questions, response.answers) + expect(invalidCheckFormAnswers).toHaveLength(7) }) }) + +/** + * + * @param haystack Return true if the needle Question is found in the array + * @param needle + */ +function isValidAnswer (haystack: CheckQuestion[], needle: CompleteCheckAnswer): boolean { + for (let i = 0; i < haystack.length; i++) { + const questionToTest = haystack[i] + if (needle.factor1 === questionToTest.factor1 && needle.factor2 === questionToTest.factor2) { + return true + } + } + return false +} + +function filterValidAnswers (questions: CheckQuestion[], answers: CompleteCheckAnswer[]): CompleteCheckAnswer[] { + return answers.filter((ans) => { return isValidAnswer(questions, ans) }) +} + +function filterInValidAnswers (questions: CheckQuestion[], answers: CompleteCheckAnswer[]): CompleteCheckAnswer[] { + return answers.filter((ans) => { return !isValidAnswer(questions, ans) }) +} diff --git a/tslib/src/functions/util-submit-check/fake-completed-check-generator.service.ts b/tslib/src/functions/util-submit-check/fake-completed-check-generator.service.ts index f25a276cc1..0eb8003a9b 100644 --- a/tslib/src/functions/util-submit-check/fake-completed-check-generator.service.ts +++ b/tslib/src/functions/util-submit-check/fake-completed-check-generator.service.ts @@ -1,9 +1,7 @@ import { PreparedCheck } from '../../schemas/check-schemas/prepared-check' -import { CheckQuestion, CompleteCheckAnswer, ValidCheck } from '../../schemas/check-schemas/validated-check' -import * as faker from 'faker' +import { CheckQuestion, CompleteCheckAnswer, CompleteCheckAuditEntry, CompleteCheckInputEntry, ValidCheck, InputEventType, AuditEntryType } from '../../schemas/check-schemas/validated-check' +import { faker } from '@faker-js/faker' import moment from 'moment' -import { FakeCheckAuditGeneratorService } from './fake-check-audit-generator.service' -import { FakeCheckInputsGeneratorService } from './fake-check-inputs-generator.service' import { IUtilSubmitCheckConfig } from '.' import { Answer } from '../check-marker/models' @@ -12,14 +10,6 @@ export interface ICompletedCheckGeneratorService { } export class FakeCompletedCheckGeneratorService implements ICompletedCheckGeneratorService { - private readonly fakeCheckAuditBuilderService: FakeCheckAuditGeneratorService - private readonly fakeCheckInputsGeneratorService: FakeCheckInputsGeneratorService - - constructor () { - this.fakeCheckAuditBuilderService = new FakeCheckAuditGeneratorService() - this.fakeCheckInputsGeneratorService = new FakeCheckInputsGeneratorService() - } - private readonly languages = ['en-GB', 'en-US', 'en', 'en-ie'] private readonly platforms = ['win32', 'MacIntel', 'Win64', 'LINUX X86_64'] private readonly userAgents = [ @@ -43,53 +33,142 @@ export class FakeCompletedCheckGeneratorService implements ICompletedCheckGenera }) } - protected createAnswers (questions: CheckQuestion[], numberFromCorrectCheckForm: number = questions.length, numberFromIncorrectCheckForm: number = 0): CompleteCheckAnswer[] { - const answers: Answer[] = [] - for (let i = 0; i < numberFromCorrectCheckForm; i++) { - const q = questions[i] - const correctAnswer = q.factor1 * q.factor2 - answers.push({ - answer: `${correctAnswer}`, - clientTimestamp: moment().add(q.order, 'seconds').toISOString(), - factor1: q.factor1, - factor2: q.factor2, - question: `${q.factor1}x${q.factor2}`, - sequenceNumber: q.order - }) + private createAuditEvent (auditType: AuditEntryType, dt: moment.Moment, question?: CheckQuestion, isWarmup?: boolean): CompleteCheckAuditEntry { + const data: any = {} + if (question !== undefined) { + data.question = `${question.factor1}x${question.factor2}` + data.sequenceNumber = question.order + if (isWarmup !== undefined) { + data.isWarmup = isWarmup + } else { + data.isWarmup = false + } + } + + return { + type: auditType, + clientTimestamp: dt.toISOString(), + data + } + } + + private createMockResponse (questionNumber: number, question: CheckQuestion, baseTime: moment.Moment, wantRandomAnswer: boolean = false): { answer: CompleteCheckAnswer, inputs: CompleteCheckInputEntry[], audits: CompleteCheckAuditEntry[] } { + // the answer as a string + const input: string = wantRandomAnswer ? faker.datatype.number({ min: 1, max: 150 }).toString() : (question.factor1 * question.factor2).toString() + const audits: CompleteCheckAuditEntry[] = [] + + // Add 3 seconds to mimic the load screen + audits.push(this.createAuditEvent(AuditEntryType.PauseRendered, baseTime, question, false)) + baseTime.add(3, 'seconds') + // Show the question + audits.push(this.createAuditEvent(AuditEntryType.QuestionRendered, baseTime, question, false)) + audits.push(this.createAuditEvent(AuditEntryType.QuestionTimerStarted, baseTime, question, false)) + + // Capture the inputs and the question + const inputs = [...input].map(num => { + const thinkingDelayMs = faker.datatype.number({ min: 150, max: 1800 }) + baseTime.add(thinkingDelayMs, 'milliseconds') + + return { + input: num, + clientTimestamp: baseTime.toISOString(), + eventType: InputEventType.Keyboard, + question: `${question.factor1}x${question.factor2}`, + sequenceNumber: questionNumber + } + }) + + // And add an Enter button keypress to the tail of the inputs + const thinkingDelayMs = faker.datatype.number({ min: 500, max: 1800 }) + baseTime.add(thinkingDelayMs, 'milliseconds') + inputs.push({ + input: 'Enter', + clientTimestamp: baseTime.toISOString(), + eventType: InputEventType.Keyboard, + question: `${question.factor1}x${question.factor2}`, + sequenceNumber: questionNumber + }) + + // Capture the Timer cancelled and Answer events + audits.push(this.createAuditEvent(AuditEntryType.QuestionTimerCancelled, baseTime, question, false)) + audits.push(this.createAuditEvent(AuditEntryType.QuestionAnswered, baseTime, question, false)) + + // construct the answer object to be included in the payload + const answer: Answer = { + answer: input, + clientTimestamp: baseTime.toISOString(), // the answer timestamp is the same as the last input ('Enter' key) + factor1: question.factor1, + factor2: question.factor2, + question: `${question.factor1}x${question.factor2}`, + sequenceNumber: questionNumber } - const numberOfPotentiallyWrongAnswers = faker.datatype.number({ min: 0, max: answers.length }) - for (let index = 0; index < numberOfPotentiallyWrongAnswers; index++) { - const answer = faker.random.arrayElement(answers) - // just pick any number in the given range, it could be correct or not... - const newAnswer = faker.datatype.number({ min: 0, max: 144 }) - answer.answer = `${newAnswer}` + return { answer, inputs, audits } + } + + /** + * For a single check create a complete set of answers, inputs and audits (events) that are consistent with each other so that the event timings are valid and correct looking. + * @param num + * @returns + */ + createResponses (questions: CheckQuestion[], numberFromCorrectCheckForm: number = questions.length, numberFromIncorrectCheckForm: number = 0, numberOfDuplicateAnswers: number = 0): { answers: CompleteCheckAnswer[], inputs: CompleteCheckInputEntry[], audits: CompleteCheckAuditEntry[] } { + const responses: { answers: CompleteCheckAnswer[], inputs: CompleteCheckInputEntry[], audits: CompleteCheckAuditEntry[] } = { answers: [], inputs: [], audits: [] } + + // The check starts now. We will pass `dt` around by reference and keep adding more time to it, to + // make the timings look authentic. + const dt = moment() + responses.audits.push(this.createAuditEvent(AuditEntryType.CheckStarted, dt)) + + for (let i = 0; i < numberFromCorrectCheckForm; i++) { + const resp = this.createMockResponse(i + 1, questions[i], dt, faker.datatype.boolean()) + responses.answers.push(resp.answer) + responses.inputs.push(...resp.inputs) + responses.audits.push(...resp.audits) } - // Optionally add some new answers from wrong forms mimining data corruption in local storage for (let i = 0; i < numberFromIncorrectCheckForm; i++) { - const index = answers.length + i - answers.push({ - answer: '1', - clientTimestamp: moment().add(index, 'seconds').toISOString(), - factor1: 0, - factor2: index, - question: `0x${index}`, - sequenceNumber: index - }) + const randomQuestion = { + order: faker.datatype.number({ min: 1, max: 25 }), + factor1: 13, // Using 13 provides a way of ensuring that this question is not in the questions array provided as the first arg to this function. + factor2: faker.datatype.number({ min: 1, max: 12 }) + } + const resp = this.createMockResponse(i + 1, randomQuestion, dt, faker.datatype.boolean()) + responses.answers.push(resp.answer) + responses.inputs.push(...resp.inputs) + responses.audits.push(...resp.audits) } - return answers + if (numberOfDuplicateAnswers > 0) { + if (numberOfDuplicateAnswers > 25) { // You might want more duplicates. This seems enough though. + throw new Error('Error: too many duplicates requested') + } + for (let i = 0; i < numberOfDuplicateAnswers; i++) { + const qidx = i + 1 <= questions.length ? i : questions.length - 1 + const resp = this.createMockResponse(qidx + 1, questions[qidx], dt, faker.datatype.boolean()) + responses.answers.push(resp.answer) + responses.inputs.push(...resp.inputs) + responses.audits.push(...resp.audits) + } + } + + // Add some final audits + dt.add(1, 'millisecond') + responses.audits.push(this.createAuditEvent(AuditEntryType.CheckSubmissionPending, dt)) + dt.add(1, 'millisecond') + responses.audits.push(this.createAuditEvent(AuditEntryType.CheckSubmissionApiCalled, dt)) + + return responses } create (preparedCheck: PreparedCheck, funcConfig?: IUtilSubmitCheckConfig): ValidCheck { - const answers = this.createAnswers(preparedCheck.questions, funcConfig?.answers?.numberFromCorrectCheckForm, funcConfig?.answers?.numberFromIncorrectCheckForm) - const audits = this.fakeCheckAuditBuilderService.createAudits(preparedCheck.questions) - const inputs = this.fakeCheckInputsGeneratorService.create(answers) + const response = this.createResponses(preparedCheck.questions, + funcConfig?.answers?.numberFromCorrectCheckForm, + funcConfig?.answers?.numberFromIncorrectCheckForm, + funcConfig?.answers?.numberOfDuplicateAnswers) return { - answers: answers, - audit: audits, + answers: response.answers, + audit: response.audits, checkCode: preparedCheck.checkCode, config: preparedCheck.config, device: { @@ -107,27 +186,27 @@ export class FakeCompletedCheckGeneratorService implements ICompletedCheckGenera navigator: { cookieEnabled: faker.datatype.boolean(), doNotTrack: faker.datatype.boolean(), - language: faker.random.arrayElement(this.languages), - platform: faker.random.arrayElement(this.platforms), - userAgent: faker.random.arrayElement(this.userAgents) + language: faker.helpers.arrayElement(this.languages), + platform: faker.helpers.arrayElement(this.platforms), + userAgent: faker.helpers.arrayElement(this.userAgents) }, networkConnection: { downlink: 1, - effectiveType: faker.random.arrayElement(this.connectionTypes), + effectiveType: faker.helpers.arrayElement(this.connectionTypes), rtt: 1 }, screen: { colorDepth: 24, innerHeight: this.randomScreenValue(), innerWidth: this.randomScreenValue(), - orientation: faker.random.arrayElement(this.orientations), + orientation: faker.helpers.arrayElement(this.orientations), outerHeight: this.randomScreenValue(), outerWidth: this.randomScreenValue(), screenHeight: this.randomScreenValue(), screenWidth: this.randomScreenValue() } }, - inputs: inputs, + inputs: response.inputs, pupil: { checkCode: preparedCheck.checkCode, inputAssistant: { @@ -137,7 +216,7 @@ export class FakeCompletedCheckGeneratorService implements ICompletedCheckGenera }, questions: preparedCheck.questions, school: { - name: faker.company.companyName(), + name: faker.company.name(), uuid: preparedCheck.school.uuid }, schoolUUID: preparedCheck.school.uuid, diff --git a/tslib/src/functions/util-submit-check/fake-submitted-check-generator.service.ts b/tslib/src/functions/util-submit-check/fake-submitted-check-generator.service.ts index 8fc0ad7e88..291989e3b6 100644 --- a/tslib/src/functions/util-submit-check/fake-submitted-check-generator.service.ts +++ b/tslib/src/functions/util-submit-check/fake-submitted-check-generator.service.ts @@ -4,12 +4,14 @@ import { ICompletedCheckGeneratorService, FakeCompletedCheckGeneratorService } f import { CompressionService, ICompressionService } from '../../common/compression-service' import { IPreparedCheckService, PreparedCheckService } from '../../caching/prepared-check.service' import { IUtilSubmitCheckConfig } from './index' +import { ILogger } from '../../common/logger' export class FakeSubmittedCheckMessageGeneratorService { private readonly completedCheckGenerator: ICompletedCheckGeneratorService private readonly compressionService: ICompressionService private readonly prepCheckService: IPreparedCheckService private funcConfig: IUtilSubmitCheckConfig | undefined + private logger: ILogger | undefined constructor (submittedCheckBuilder?: ICompletedCheckGeneratorService, compressionService?: ICompressionService, prepCheckService?: IPreparedCheckService) { if (submittedCheckBuilder === undefined) { @@ -27,9 +29,14 @@ export class FakeSubmittedCheckMessageGeneratorService { } setConfig (funcConfig: IUtilSubmitCheckConfig): void { + this.logger?.info('funcConfig is', funcConfig) this.funcConfig = funcConfig } + setLogger (logger: ILogger): void { + this.logger = logger + } + async createSubmittedCheckMessage (checkCode: string): Promise { const preparedCheckCacheValue = await this.prepCheckService.fetch(checkCode) if (preparedCheckCacheValue === undefined) { diff --git a/tslib/src/functions/util-submit-check/index.ts b/tslib/src/functions/util-submit-check/index.ts index d2f6c84d04..47529b0059 100644 --- a/tslib/src/functions/util-submit-check/index.ts +++ b/tslib/src/functions/util-submit-check/index.ts @@ -9,10 +9,11 @@ const liveSchoolChecksDataService = new SchoolChecksDataService() export interface IUtilSubmitCheckConfig { schoolUuid?: string // Use schoolUuid to complete an entire school at once, OR - checkCodes?: string[] // use `checkCdodes` to have fine grain control of specific checks. + checkCodes?: string[] // use `checkCodes` to have fine grain control of specific checks. answers?: { numberFromCorrectCheckForm: number // the number of answers from the correct check form numberFromIncorrectCheckForm: number // the number of answers from some other check form that bulk up the answer count to the expected level. + numberOfDuplicateAnswers: number // mimic the pupil answering the question for a second time } } @@ -28,11 +29,13 @@ const httpTrigger: AzureFunction = async function (context: Context, req: HttpRe checkCodes: req.body?.checkCodes, answers: { numberFromCorrectCheckForm: req.body?.answerNumberFromCorrectCheckForm, - numberFromIncorrectCheckForm: req.body?.answerNumberFromIncorrectCheckForm + numberFromIncorrectCheckForm: req.body?.answerNumberFromIncorrectCheckForm, + numberOfDuplicateAnswers: req.body?.answerNumberOfDuplicates } } context.log(`${functionName} config parsed as: ${JSON.stringify(funcConfig)})`) const fakeSubmittedCheckBuilder = new FakeSubmittedCheckMessageGeneratorService() + fakeSubmittedCheckBuilder.setLogger(context.log) fakeSubmittedCheckBuilder.setConfig(funcConfig) if (funcConfig.schoolUuid !== undefined) { const liveCheckCodes = await liveSchoolChecksDataService.fetchBySchoolUuid(funcConfig.schoolUuid) diff --git a/tslib/src/tests-integration/mock-payload.class.ts b/tslib/src/tests-integration/mock-payload.class.ts index f7dbe9ef62..e8dea43795 100644 --- a/tslib/src/tests-integration/mock-payload.class.ts +++ b/tslib/src/tests-integration/mock-payload.class.ts @@ -1,5 +1,5 @@ import moment from 'moment' -import faker from 'faker' +import { faker } from '@faker-js/faker' import { DfEAbsenceCode, IPsychometricReportLine, IReportLineAnswer } from '../functions-ps-report/ps-report-3-transformer/models' const schools = [ 'The New Learning Centre', @@ -133,8 +133,8 @@ export class MockReportLineAnswer implements IReportLineAnswer { this.questionNumber = questionNumber this.id = `${questionNumber}x${questionNumber}` this.response = faker.datatype.number({ min: 0, max: 144 }).toString() - this.inputMethods = faker.random.arrayElement(['k', 'm', 'p', 't', 'x']) - this.keystrokes = this.response.split('').map(v => `${faker.random.arrayElement(['k', 'm', 'p', 't'])}[${v}]`).join(', ') + this.inputMethods = faker.helpers.arrayElement(['k', 'm', 'p', 't', 'x']) + this.keystrokes = this.response.split('').map(v => `${faker.helpers.arrayElement(['k', 'm', 'p', 't'])}[${v}]`).join(', ') this.score = faker.datatype.number({ min: 0, max: 100 }) > 75 ? 0 : 1 this.firstKey = moment().subtract(faker.datatype.number({ min: 0, max: 100 }), 'minutes') this.lastKey = moment().subtract(faker.datatype.number({ min: 0, max: 100 }), 'minutes') @@ -192,12 +192,12 @@ export class MockPayload implements IPsychometricReportLine { const tenYearsAgo = moment().subtract(10, 'years') const nineYearsAgo = moment().subtract(9, 'years') this.DOB = moment.utc(faker.date.between(tenYearsAgo.toDate(), nineYearsAgo.toDate())).startOf('day') - this.Gender = faker.random.arrayElement(['M', 'F']) + this.Gender = faker.helpers.arrayElement(['M', 'F']) this.Forename = faker.name.firstName() this.Surname = faker.name.lastName() - this.ReasonNotTakingCheck = faker.random.arrayElement(['A', 'Z', 'L', 'U', 'B', 'J']) + this.ReasonNotTakingCheck = faker.helpers.arrayElement(['A', 'Z', 'L', 'U', 'B', 'J']) this.PupilStatus = faker.helpers.shuffle(['Incomplete', 'Complete', 'Not taking the Check'])[0] - this.SchoolName = faker.random.arrayElement(schools) + this.SchoolName = faker.helpers.arrayElement(schools) this.Estab = faker.datatype.number({ min: 1000, max: 9999 }) this.SchoolURN = faker.datatype.number({ min: 89000, max: 89999 }) this.LAnum = faker.datatype.number({ min: 201, max: 999 }) @@ -205,7 +205,7 @@ export class MockPayload implements IPsychometricReportLine { this.PauseLength = faker.datatype.float({ min: 3.00, max: 6.00 }) this.AccessArr = faker.datatype.number({ min: 1, max: 6 }).toString() this.AttemptID = faker.datatype.uuid() - this.FormID = faker.random.arrayElement(['MTC001', 'MTC002', 'MTC003', 'MTC004', 'MTC005', 'MTC006', 'MTC007']) + this.FormID = faker.helpers.arrayElement(['MTC001', 'MTC002', 'MTC003', 'MTC004', 'MTC005', 'MTC006', 'MTC007']) this.TestDate = moment().subtract(13, 'minutes') this.TimeStart = moment().subtract(faker.datatype.number({ min: 1, max: 30 }), 'minutes') this.TimeComplete = moment() diff --git a/tslib/yarn.lock b/tslib/yarn.lock index 242c8bae9e..3614452ab5 100644 --- a/tslib/yarn.lock +++ b/tslib/yarn.lock @@ -600,6 +600,11 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" +"@faker-js/faker@^7.6.0": + version "7.6.0" + resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-7.6.0.tgz#9ea331766084288634a9247fcd8b84f16ff4ba07" + integrity sha512-XK6BTq1NDMo9Xqw/YkYyGjSsg44fbNwYRx7QK2CuoQgyy+f1rrTDHoExVM5PsyXCtfl2vs2vVJ0MN0yN6LppRw== + "@humanwhocodes/config-array@^0.5.0": version "0.5.0" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9" @@ -992,11 +997,6 @@ dependencies: "@types/node" "*" -"@types/faker@^5.5.8": - version "5.5.9" - resolved "https://registry.yarnpkg.com/@types/faker/-/faker-5.5.9.tgz#588ede92186dc557bff8341d294335d50d255f0c" - integrity sha512-uCx6mP3UY5SIO14XlspxsGjgaemrxpssJI0Ol+GfhxtcKpv9pgRZYsS4eeKeHVLje6Qtc8lGszuBI461+gVZBA== - "@types/graceful-fs@^4.1.2": version "4.1.5" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.5.tgz#21ffba0d98da4350db64891f92a9e5db3cdb4e15" @@ -2526,11 +2526,6 @@ extglob@^2.0.4: snapdragon "^0.8.1" to-regex "^3.0.1" -faker@^5.5.3: - version "5.5.3" - resolved "https://registry.yarnpkg.com/faker/-/faker-5.5.3.tgz#c57974ee484431b25205c2c8dc09fda861e51e0e" - integrity sha512-wLTv2a28wjUyWkbnX7u/ABZBkUkIF2fCd73V6P2oFqEGEktDfzWx4UxrSqtPRw0xPRAcjeAOIiJWqZm3pP4u3g== - fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"