From 8e6c4a2eb41f6abd3eb68363d346b76c1ea1fd67 Mon Sep 17 00:00:00 2001 From: Steph0 Date: Thu, 8 Aug 2024 10:38:17 +0200 Subject: [PATCH 1/3] :bug: api: let runtime errors bubble from certification downgrade strategy --- ...-next-challenge-for-campaign-assessment.js | 8 + .../scenario-simulator-controller.js | 42 +---- .../application/scenario-simulator-route.js | 23 --- .../domain/models/AssessmentSimulator.js | 15 +- ...ssessmentSimulatorSingleMeasureStrategy.js | 8 +- .../domain/models/FlashAssessmentAlgorithm.js | 31 ++- ...get-next-challenge-for-v3-certification.js | 9 + .../domain/usecases/index.js | 1 - .../scenario-simulator-controller_test.js | 91 ++------- .../scenario-simulator-controller_test.js | 177 ------------------ .../scoring-degradation-service_test.js | 2 +- .../models/FlashAssessmentAlgorithm_test.js | 35 +++- ...-challenge-for-campaign-assessment_test.js | 9 +- ...-deterministic-assessment-scenario_test.js | 20 +- 14 files changed, 115 insertions(+), 356 deletions(-) rename api/tests/certification/scoring/{unit/domain => integration/application}/services/scoring-degradation-service_test.js (96%) diff --git a/api/lib/domain/usecases/get-next-challenge-for-campaign-assessment.js b/api/lib/domain/usecases/get-next-challenge-for-campaign-assessment.js index 38ba97a2f72..d38106bedf1 100644 --- a/api/lib/domain/usecases/get-next-challenge-for-campaign-assessment.js +++ b/api/lib/domain/usecases/get-next-challenge-for-campaign-assessment.js @@ -38,6 +38,10 @@ const getNextChallengeForCampaignAssessment = async function ({ challenges, }); + if (_hasAnsweredToAllChallenges({ possibleChallenges })) { + throw new AssessmentEndedError(); + } + return pickChallengeService.chooseNextChallenge(assessment.id)({ possibleChallenges }); } else { const inputValues = await algorithmDataFetcherService.fetchForCampaigns(...arguments); @@ -55,6 +59,10 @@ const getNextChallengeForCampaignAssessment = async function ({ } }; +const _hasAnsweredToAllChallenges = ({ possibleChallenges }) => { + return possibleChallenges.length === 0; +}; + const _createDefaultAlgorithmConfiguration = () => { return new FlashAssessmentAlgorithmConfiguration({ warmUpLength: 0, diff --git a/api/src/certification/flash-certification/application/scenario-simulator-controller.js b/api/src/certification/flash-certification/application/scenario-simulator-controller.js index 9dfd10a3195..3bb32150a5f 100644 --- a/api/src/certification/flash-certification/application/scenario-simulator-controller.js +++ b/api/src/certification/flash-certification/application/scenario-simulator-controller.js @@ -2,9 +2,7 @@ import { Readable } from 'node:stream'; import _ from 'lodash'; -import { parseCsv } from '../../../../scripts/helpers/csvHelpers.js'; import { pickChallengeService } from '../../../evaluation/domain/services/pick-challenge-service.js'; -import { HttpErrors } from '../../../shared/application/http-errors.js'; import { random } from '../../../shared/infrastructure/utils/random.js'; import { extractLocaleFromRequest } from '../../../shared/infrastructure/utils/request-response-utils.js'; import { pickAnswerStatusService } from '../../shared/domain/services/pick-answer-status-service.js'; @@ -97,40 +95,6 @@ async function simulateFlashAssessmentScenario( return h.response(generatedResponse).type('text/event-stream; charset=utf-8'); } -async function importScenarios( - request, - h, - dependencies = { parseCsv, pickChallengeService, scenarioSimulatorBatchSerializer, extractLocaleFromRequest }, -) { - const parsedCsvData = await dependencies.parseCsv(request.payload.path); - - if (!_isValidAnswerStatusArray(parsedCsvData)) { - return new HttpErrors.BadRequestError("Each CSV cell must be one of 'ok', 'ko' or 'aband'"); - } - - const locale = dependencies.extractLocaleFromRequest(request); - - const results = ( - await Promise.all( - parsedCsvData.map(async (answerStatusArray, index) => { - const pickAnswerStatus = pickAnswerStatusService.pickAnswerStatusFromArray(answerStatusArray); - const pickChallenge = dependencies.pickChallengeService.chooseNextChallenge(index); - - return usecases.simulateFlashDeterministicAssessmentScenario({ - pickAnswerStatus, - pickChallenge, - locale, - }); - }), - ) - ).map((simulationReport, index) => ({ - index, - simulationReport, - })); - - return dependencies.scenarioSimulatorBatchSerializer.serialize(results); -} - function _getPickAnswerStatusMethod(pickAnswerStatusService, payload) { const { type, probabilities, length, capacity, answerStatusArray } = payload; @@ -146,10 +110,6 @@ function _getPickAnswerStatusMethod(pickAnswerStatusService, payload) { } } -function _isValidAnswerStatusArray(answerStatusArray) { - return answerStatusArray.every((row) => row.every((cell) => ['ok', 'ko', 'aband'].includes(cell))); -} - function _generateAnswerStatusArray(random, probabilities, length) { return random.weightedRandoms(probabilities, length); } @@ -164,4 +124,4 @@ function _minimumEstimatedSuccessRateRangesToDomain(successRateRanges) { }); } -export const scenarioSimulatorController = { simulateFlashAssessmentScenario, importScenarios }; +export const scenarioSimulatorController = { simulateFlashAssessmentScenario }; diff --git a/api/src/certification/flash-certification/application/scenario-simulator-route.js b/api/src/certification/flash-certification/application/scenario-simulator-route.js index 2204c175d47..5614e73ab6f 100644 --- a/api/src/certification/flash-certification/application/scenario-simulator-route.js +++ b/api/src/certification/flash-certification/application/scenario-simulator-route.js @@ -82,29 +82,6 @@ const register = async (server) => { ], }, }, - { - method: 'POST', - path: '/api/scenario-simulator/csv-import', - config: { - pre: [ - { - method: securityPreHandlers.checkAdminMemberHasRoleSuperAdmin, - assign: 'hasAuthorizationToAccessAdminScope', - }, - ], - handler: scenarioSimulatorController.importScenarios, - payload: { - maxBytes: 20715200, - output: 'file', - parse: 'gunzip', - }, - tags: ['api'], - notes: [ - '- **Cette route est restreinte aux utilisateurs authentifiés**\n' + - '- Elle permet de générer la liste de challenges passés avec le nouvel algorithme ainsi que le niveau estimé, pour une liste de réponses données via un import de fichier CSV', - ], - }, - }, ]); }; diff --git a/api/src/certification/flash-certification/domain/models/AssessmentSimulator.js b/api/src/certification/flash-certification/domain/models/AssessmentSimulator.js index 180baf0c4b6..1f58623dae6 100644 --- a/api/src/certification/flash-certification/domain/models/AssessmentSimulator.js +++ b/api/src/certification/flash-certification/domain/models/AssessmentSimulator.js @@ -12,19 +12,14 @@ export class AssessmentSimulator { // eslint-disable-next-line no-constant-condition while (true) { - try { - const simulatorStepResult = this.getStrategy(stepIndex).run({ challengesAnswers, stepIndex }); + const simulatorStepResult = this.getStrategy(stepIndex).run({ challengesAnswers, stepIndex }); - if (!simulatorStepResult) { - break; - } - stepIndex = simulatorStepResult.nextStepIndex; - challengesAnswers.push(...simulatorStepResult.challengeAnswers); - result.push(...simulatorStepResult.results); - } catch (error) { - logger.error(error); + if (!simulatorStepResult) { break; } + stepIndex = simulatorStepResult.nextStepIndex; + challengesAnswers.push(...simulatorStepResult.challengeAnswers); + result.push(...simulatorStepResult.results); } logger.trace({ result }, 'AssessmentSimulator result'); diff --git a/api/src/certification/flash-certification/domain/models/AssessmentSimulatorSingleMeasureStrategy.js b/api/src/certification/flash-certification/domain/models/AssessmentSimulatorSingleMeasureStrategy.js index 4539156ec0a..a68d6ad3ba0 100644 --- a/api/src/certification/flash-certification/domain/models/AssessmentSimulatorSingleMeasureStrategy.js +++ b/api/src/certification/flash-certification/domain/models/AssessmentSimulatorSingleMeasureStrategy.js @@ -18,14 +18,16 @@ export class AssessmentSimulatorSingleMeasureStrategy { const nextChallenge = this.pickChallenge({ possibleChallenges }); + if (!nextChallenge) { + return null; + } + const answerStatus = this.pickAnswerStatus({ answerIndex: stepIndex, nextChallenge, }); - const noMoreAnswerRemaining = !answerStatus; - - if (noMoreAnswerRemaining) { + if (!answerStatus) { return null; } diff --git a/api/src/certification/flash-certification/domain/models/FlashAssessmentAlgorithm.js b/api/src/certification/flash-certification/domain/models/FlashAssessmentAlgorithm.js index 2499fc821f1..6d43d870103 100644 --- a/api/src/certification/flash-certification/domain/models/FlashAssessmentAlgorithm.js +++ b/api/src/certification/flash-certification/domain/models/FlashAssessmentAlgorithm.js @@ -40,8 +40,13 @@ class FlashAssessmentAlgorithm { initialCapacity = config.v3Certification.defaultCandidateCapacity, answersForComputingCapacity, }) { - if (assessmentAnswers.length >= this._configuration.maximumAssessmentLength) { - throw new AssessmentEndedError(); + const maximumAssessmentLength = this._configuration.maximumAssessmentLength; + if (assessmentAnswers?.length > maximumAssessmentLength) { + throw new AssessmentEndedError('User answered more questions than allowed'); + } + + if (this.#hasAnsweredToAllChallenges({ assessmentAnswers, maximumAssessmentLength })) { + return []; } const { capacity } = this.getCapacityAndErrorRate({ @@ -50,13 +55,13 @@ class FlashAssessmentAlgorithm { initialCapacity, }); - const challengesAfterRulesApplication = this._applyChallengeSelectionRules(assessmentAnswers, challenges); + const challengesAfterRulesApplication = this.#applyChallengeSelectionRules(assessmentAnswers, challenges); if (challengesAfterRulesApplication?.length === 0) { - throw new AssessmentEndedError(); + throw new AssessmentEndedError('No eligible challenges in referential'); } - const minimalSuccessRate = this._computeMinimalSuccessRate(assessmentAnswers.length); + const minimalSuccessRate = this.#computeMinimalSuccessRate(assessmentAnswers.length); return this.flashAlgorithmImplementation.getPossibleNextChallenges({ availableChallenges: challengesAfterRulesApplication, @@ -68,15 +73,23 @@ class FlashAssessmentAlgorithm { }); } - _applyChallengeSelectionRules(assessmentAnswers, challenges) { + #hasAnsweredToAllChallenges({ assessmentAnswers, maximumAssessmentLength }) { + if (assessmentAnswers && assessmentAnswers.length === maximumAssessmentLength) { + return true; + } + + return false; + } + + #applyChallengeSelectionRules(assessmentAnswers, challenges) { return this.ruleEngine.execute({ assessmentAnswers, allChallenges: challenges, }); } - _computeMinimalSuccessRate(questionIndex) { - const filterConfiguration = this._findApplicableSuccessRateConfiguration(questionIndex); + #computeMinimalSuccessRate(questionIndex) { + const filterConfiguration = this.#findApplicableSuccessRateConfiguration(questionIndex); if (!filterConfiguration) { return 0; @@ -85,7 +98,7 @@ class FlashAssessmentAlgorithm { return filterConfiguration.getMinimalSuccessRate(questionIndex); } - _findApplicableSuccessRateConfiguration(questionIndex) { + #findApplicableSuccessRateConfiguration(questionIndex) { return this._configuration.minimumEstimatedSuccessRateRanges.find((successRateRange) => successRateRange.isApplicable(questionIndex), ); diff --git a/api/src/certification/session-management/domain/usecases/get-next-challenge-for-v3-certification.js b/api/src/certification/session-management/domain/usecases/get-next-challenge-for-v3-certification.js index e5b43cded1d..d6bc1d20b7e 100644 --- a/api/src/certification/session-management/domain/usecases/get-next-challenge-for-v3-certification.js +++ b/api/src/certification/session-management/domain/usecases/get-next-challenge-for-v3-certification.js @@ -9,6 +9,7 @@ * @typedef {import('./index.js').FlashAlgorithmService} FlashAlgorithmService */ +import {AssessmentEndedError} from '../../../../shared/domain/errors.js'; import { CertificationChallenge, FlashAssessmentAlgorithm } from '../../../../shared/domain/models/index.js'; /** @@ -81,6 +82,10 @@ const getNextChallengeForV3Certification = async function ({ challenges: challengesWithoutSkillsWithAValidatedLiveAlert, }); + if (_hasAnsweredToAllChallenges({ possibleChallenges })) { + throw new AssessmentEndedError(); + } + const challenge = pickChallengeService.chooseNextChallenge()({ possibleChallenges }); const certificationChallenge = new CertificationChallenge({ @@ -100,6 +105,10 @@ const getNextChallengeForV3Certification = async function ({ return challenge; }; +const _hasAnsweredToAllChallenges = ({ possibleChallenges }) => { + return possibleChallenges.length === 0; +}; + const _excludeChallengesWithASkillWithAValidatedLiveAlert = ({ validatedLiveAlertChallengeIds, challenges }) => { const validatedLiveAlertChallenges = challenges.filter((challenge) => { return validatedLiveAlertChallengeIds.includes(challenge.id); diff --git a/api/src/certification/session-management/domain/usecases/index.js b/api/src/certification/session-management/domain/usecases/index.js index 59eb9e130f1..c78ee7bab6a 100644 --- a/api/src/certification/session-management/domain/usecases/index.js +++ b/api/src/certification/session-management/domain/usecases/index.js @@ -72,7 +72,6 @@ import { cpfReceiptsStorage } from '../../infrastructure/storage/cpf-receipts-st * @typedef {certificationOfficerRepository} CertificationOfficerRepository * @typedef {certificationChallengeRepository} CertificationChallengeRepository * @typedef {challengeRepository} ChallengeRepository - * @typedef {competenceMarkRepository} CompetenceMarkRepository * @typedef {finalizedSessionRepository} FinalizedSessionRepository * @typedef {juryCertificationRepository} JuryCertificationRepository * @typedef {jurySessionRepository} JurySessionRepository diff --git a/api/tests/acceptance/application/scenario-simulator/scenario-simulator-controller_test.js b/api/tests/acceptance/application/scenario-simulator/scenario-simulator-controller_test.js index d46ab65a0a6..ce783d15530 100644 --- a/api/tests/acceptance/application/scenario-simulator/scenario-simulator-controller_test.js +++ b/api/tests/acceptance/application/scenario-simulator/scenario-simulator-controller_test.js @@ -18,22 +18,28 @@ describe('Acceptance | Controller | scenario-simulator-controller', function () let validDeterministicPayload; let validRandomPayload; let validCapacityPayload; - let validPayloadForBatch; + let stopAtChallenge; const answerStatusArray = ['ok', 'ko', 'aband']; beforeEach(async function () { - server = await createServer(); - const { id: adminId } = databaseBuilder.factory.buildUser.withRole({ role: SUPER_ADMIN, }); + + stopAtChallenge = databaseBuilder.factory.buildFlashAlgorithmConfiguration({ + maximumAssessmentLength: 2, + createdAt: new Date('2022-02-01'), + }).maximumAssessmentLength; + adminAuthorization = generateValidRequestAuthorizationHeader(adminId); await databaseBuilder.commit(); validDeterministicPayload = { answerStatusArray, type: 'deterministic', + stopAtChallenge, }; + validRandomPayload = { type: 'random', probabilities: { @@ -42,13 +48,15 @@ describe('Acceptance | Controller | scenario-simulator-controller', function () aband: 0.2, }, length: 5, + stopAtChallenge, }; - validPayloadForBatch = `ok,ko,aband -ko,aband,ok`; + validCapacityPayload = { capacity: 4.5, type: 'capacity', + stopAtChallenge, }; + const learningContent = { competences: [ { @@ -145,6 +153,8 @@ ko,aband,ok`; }; mockLearningContent(learningContent); + + server = await createServer(); }); describe('#simulateFlashAssessmentScenario', function () { @@ -164,7 +174,7 @@ ko,aband,ok`; // given const validPayload = { ...validDeterministicPayload, - stopAtChallenge: 2, + stopAtChallenge, }; options.headers.authorization = adminAuthorization; options.payload = validPayload; @@ -276,73 +286,4 @@ ko,aband,ok`; }); }); }); - - describe('#importScenarios', function () { - let options; - - beforeEach(async function () { - options = { - method: 'POST', - url: '/api/scenario-simulator/csv-import', - payload: {}, - headers: {}, - }; - }); - - it('should return a payload with simulation deterministic scenario results', async function () { - // given - options.headers.authorization = adminAuthorization; - options.payload = validPayloadForBatch; - - // when - const response = await server.inject(options); - - // then - expect(response).to.have.property('statusCode', 200); - expect(response.result.data).to.have.lengthOf(2); - }); - - describe('when there is no connected user', function () { - it('should return status code 401', async function () { - // given - options.headers.authorization = undefined; - - // when - const response = await server.inject(options); - - // then - expect(response).to.have.property('statusCode', 401); - }); - }); - - describe('when connected user does not have role SUPER_ADMIN', function () { - it('should return status code 403', async function () { - // given - const { id: userId } = databaseBuilder.factory.buildUser(); - options.headers.authorization = generateValidRequestAuthorizationHeader(userId); - await databaseBuilder.commit(); - options.payload = validPayloadForBatch; - - // when - const response = await server.inject(options); - - // then - expect(response).to.have.property('statusCode', 403); - }); - }); - - describe('when request payload is invalid', function () { - it('should return status code 400', async function () { - // given - options.headers.authorization = adminAuthorization; - options.payload = `error, anotherError`; - - // when - const response = await server.inject(options); - - // then - expect(response).to.have.property('statusCode', 400); - }); - }); - }); }); diff --git a/api/tests/certification/flash-certification/integration/application/scenario-simulator-controller_test.js b/api/tests/certification/flash-certification/integration/application/scenario-simulator-controller_test.js index 6efe7877d6d..7c5f65fb78a 100644 --- a/api/tests/certification/flash-certification/integration/application/scenario-simulator-controller_test.js +++ b/api/tests/certification/flash-certification/integration/application/scenario-simulator-controller_test.js @@ -1003,181 +1003,4 @@ describe('Integration | Application | scenario-simulator-controller', function ( }); }); }); - - describe('/api/scenario-simulator/csv-import', function () { - describe('#post', function () { - context('when the route is called with a csv file and correct headers', function () { - it('should call the usecase to validate sessions', async function () { - // given - const csvToImport = 'ok;ok\nko;ok'; - const challenge2 = domainBuilder.buildChallenge({ id: 'chall2', successProbabilityThreshold: 0.5 }); - const reward1 = 0.2; - const errorRate1 = 0.3; - const capacity1 = 0.4; - const reward2 = 0.6; - const errorRate2 = 0.7; - const capacity2 = 0.8; - const simulationResults1 = [ - { - challenge: challenge1, - reward: reward1, - errorRate: errorRate1, - capacity: capacity1, - answerStatus: 'ok', - }, - { - challenge: challenge2, - reward: reward2, - errorRate: errorRate2, - capacity: capacity2, - answerStatus: 'ok', - }, - ]; - - const pickChallengeImplementation = sinon.stub(); - pickChallengeService.chooseNextChallenge - .withArgs(0) - .returns(pickChallengeImplementation) - .withArgs(1) - .returns(pickChallengeImplementation); - - const pickAnswerStatusFromArrayImplementation1 = sinon.stub(); - const pickAnswerStatusFromArrayImplementation2 = sinon.stub(); - pickAnswerStatusService.pickAnswerStatusFromArray - .withArgs(['ok', 'ok']) - .returns(pickAnswerStatusFromArrayImplementation1) - .withArgs(['ko', 'ok']) - .returns(pickAnswerStatusFromArrayImplementation2); - - const simulationResults2 = [ - { - challenge: challenge1, - reward: reward1, - errorRate: errorRate1, - capacity: capacity1, - answerStatus: 'ko', - }, - { - challenge: challenge2, - reward: reward2, - errorRate: errorRate2, - capacity: capacity2, - answerStatus: 'ok', - }, - ]; - - usecases.simulateFlashDeterministicAssessmentScenario - .withArgs({ - pickChallenge: pickChallengeImplementation, - locale: 'en', - pickAnswerStatus: pickAnswerStatusFromArrayImplementation1, - }) - .resolves(simulationResults1); - - usecases.simulateFlashDeterministicAssessmentScenario - .withArgs({ - pickChallenge: pickChallengeImplementation, - locale: 'en', - pickAnswerStatus: pickAnswerStatusFromArrayImplementation2, - }) - .resolves(simulationResults2); - - securityPreHandlers.checkAdminMemberHasRoleSuperAdmin.returns(() => true); - - // when - const response = await httpTestServer.request( - 'POST', - '/api/scenario-simulator/csv-import', - csvToImport, - null, - { 'accept-language': 'en', 'Content-Type': 'text/csv;charset=utf-8' }, - ); - - // then - expect(response.statusCode).to.equal(200); - expect(response.result).to.deep.equal({ - data: [ - { - type: 'scenario-simulator-batches', - id: '0', - attributes: { - 'simulation-report': [ - { - 'challenge-id': challenge1.id, - 'minimum-capability': 0.6190392084062237, - reward: reward1, - 'error-rate': errorRate1, - capacity: capacity1, - 'answer-status': 'ok', - difficulty: challenge1.difficulty, - discriminant: challenge1.discriminant, - }, - { - 'challenge-id': challenge2.id, - 'minimum-capability': 0, - reward: reward2, - 'error-rate': errorRate2, - capacity: capacity2, - 'answer-status': 'ok', - difficulty: challenge2.difficulty, - discriminant: challenge2.discriminant, - }, - ], - }, - }, - { - type: 'scenario-simulator-batches', - id: '1', - attributes: { - 'simulation-report': [ - { - 'challenge-id': challenge1.id, - 'minimum-capability': 0.6190392084062237, - reward: reward1, - 'error-rate': errorRate1, - capacity: capacity1, - 'answer-status': 'ko', - difficulty: challenge1.difficulty, - discriminant: challenge1.discriminant, - }, - { - 'challenge-id': challenge2.id, - 'minimum-capability': 0, - reward: reward2, - 'error-rate': errorRate2, - capacity: capacity2, - 'answer-status': 'ok', - difficulty: challenge2.difficulty, - discriminant: challenge2.discriminant, - }, - ], - }, - }, - ], - }); - }); - }); - - context('when the route is called with a wrong csv file and correct headers', function () { - it('should send an error message', async function () { - // given - const csvToImport = 'ok;error'; - - securityPreHandlers.checkAdminMemberHasRoleSuperAdmin.returns(() => true); - - // when - const response = await httpTestServer.request( - 'POST', - '/api/scenario-simulator/csv-import', - csvToImport, - null, - { 'accept-language': 'en', 'Content-Type': 'text/csv;charset=utf-8' }, - ); - - // then - expect(response.statusCode).to.equal(400); - }); - }); - }); - }); }); diff --git a/api/tests/certification/scoring/unit/domain/services/scoring-degradation-service_test.js b/api/tests/certification/scoring/integration/application/services/scoring-degradation-service_test.js similarity index 96% rename from api/tests/certification/scoring/unit/domain/services/scoring-degradation-service_test.js rename to api/tests/certification/scoring/integration/application/services/scoring-degradation-service_test.js index e28cb9ec3af..43e2fc6a3bd 100644 --- a/api/tests/certification/scoring/unit/domain/services/scoring-degradation-service_test.js +++ b/api/tests/certification/scoring/integration/application/services/scoring-degradation-service_test.js @@ -2,7 +2,7 @@ import * as flashAlgorithmService from '../../../../../../src/certification/flas import { scoringDegradationService } from '../../../../../../src/certification/scoring/domain/services/scoring-degradation-service.js'; import { domainBuilder, expect } from '../../../../../test-helper.js'; -describe('Unit | Domain | services | scoringDegradationService', function () { +describe('Integration | Domain | services | scoringDegradationService', function () { it('should degrade the initial capacity', function () { // given const initialCapacity = 2; diff --git a/api/tests/certification/shared/unit/domain/models/FlashAssessmentAlgorithm_test.js b/api/tests/certification/shared/unit/domain/models/FlashAssessmentAlgorithm_test.js index f3cae65bd9d..72cd451cbd4 100644 --- a/api/tests/certification/shared/unit/domain/models/FlashAssessmentAlgorithm_test.js +++ b/api/tests/certification/shared/unit/domain/models/FlashAssessmentAlgorithm_test.js @@ -29,8 +29,9 @@ describe('Unit | Domain | Models | FlashAssessmentAlgorithm | FlashAssessmentAlg }); describe('#getPossibleNextChallenges', function () { - context('when enough challenges have been answered', function () { + context('when user has answered more questions than allowed', function () { it('should throw an AssessmentEndedError', function () { + // given const assessmentAnswers = [domainBuilder.buildAnswer({ id: 1 }), domainBuilder.buildAnswer({ id: 2 })]; const skill1 = domainBuilder.buildSkill({ id: 1 }); const skill2 = domainBuilder.buildSkill({ id: 2 }); @@ -42,10 +43,11 @@ describe('Unit | Domain | Models | FlashAssessmentAlgorithm | FlashAssessmentAlg const algorithm = new FlashAssessmentAlgorithm({ flashAlgorithmImplementation, configuration: _getAlgorithmConfig({ - maximumAssessmentLength: 2, + maximumAssessmentLength: 1, }), }); + // when / then expect(() => algorithm.getPossibleNextChallenges({ assessmentAnswers, @@ -56,6 +58,35 @@ describe('Unit | Domain | Models | FlashAssessmentAlgorithm | FlashAssessmentAlg }); }); + context('when user has answered to the maximun number of questions', function () { + it('should throw an AssessmentEndedError', function () { + // then + const assessmentAnswers = [domainBuilder.buildAnswer({ id: 1 }), domainBuilder.buildAnswer({ id: 2 })]; + const skill1 = domainBuilder.buildSkill({ id: 1 }); + const skill2 = domainBuilder.buildSkill({ id: 2 }); + const challenges = [ + domainBuilder.buildChallenge({ id: assessmentAnswers[0].challengeId, skill: skill1 }), + domainBuilder.buildChallenge({ competenceId: 'comp2', skill: skill2 }), + ]; + const capacity = 0; + const algorithm = new FlashAssessmentAlgorithm({ + flashAlgorithmImplementation, + configuration: _getAlgorithmConfig({ + maximumAssessmentLength: 2, + }), + }); + + // when + const nextChallenges = algorithm.getPossibleNextChallenges({ + assessmentAnswers, + challenges, + capacity, + }); + + expect(nextChallenges).to.have.lengthOf(0); + }); + }); + context('when there are challenges left to answer', function () { context('with limitToOneQuestionPerTube=true', function () { it('should limit to one challenge', function () { diff --git a/api/tests/unit/domain/usecases/get-next-challenge-for-campaign-assessment_test.js b/api/tests/unit/domain/usecases/get-next-challenge-for-campaign-assessment_test.js index d5036eac76c..90534724fc9 100644 --- a/api/tests/unit/domain/usecases/get-next-challenge-for-campaign-assessment_test.js +++ b/api/tests/unit/domain/usecases/get-next-challenge-for-campaign-assessment_test.js @@ -3,7 +3,7 @@ import * as flash from '../../../../src/certification/flash-certification/domain import { config } from '../../../../src/shared/config.js'; import { AssessmentEndedError } from '../../../../src/shared/domain/errors.js'; import { AnswerStatus } from '../../../../src/shared/domain/models/index.js'; -import { domainBuilder, expect, sinon } from '../../../test-helper.js'; +import { catchErr, domainBuilder, expect, sinon } from '../../../test-helper.js'; describe('Unit | Domain | Use Cases | get-next-challenge-for-campaign-assessment', function () { describe('#get-next-challenge-for-campaign-assessment', function () { @@ -307,8 +307,10 @@ describe('Unit | Domain | Use Cases | get-next-challenge-for-campaign-assessment possibleChallenges: [secondChallenge], }); + pickChallengeService.chooseNextChallenge.withArgs(assessment.id).returns(); + // when - const getNextChallengePromise = getNextChallengeForCampaignAssessment({ + const error = await catchErr(getNextChallengeForCampaignAssessment)({ challengeRepository, answerRepository, flashAlgorithmConfigurationRepository, @@ -321,7 +323,8 @@ describe('Unit | Domain | Use Cases | get-next-challenge-for-campaign-assessment }); // then - return expect(getNextChallengePromise).to.be.rejectedWith(AssessmentEndedError); + expect(error).to.be.an.instanceOf(AssessmentEndedError); + expect(error.message).to.equal('Evaluation terminée.'); }); }); }); diff --git a/api/tests/unit/domain/usecases/simulate-flash-deterministic-assessment-scenario_test.js b/api/tests/unit/domain/usecases/simulate-flash-deterministic-assessment-scenario_test.js index 955f7638420..9cad4c261cd 100644 --- a/api/tests/unit/domain/usecases/simulate-flash-deterministic-assessment-scenario_test.js +++ b/api/tests/unit/domain/usecases/simulate-flash-deterministic-assessment-scenario_test.js @@ -2,8 +2,9 @@ import _ from 'lodash'; import { simulateFlashDeterministicAssessmentScenario } from '../../../../src/certification/flash-certification/domain/usecases/simulate-flash-deterministic-assessment-scenario.js'; import { config } from '../../../../src/shared/config.js'; +import { AssessmentEndedError } from '../../../../src/shared/domain/errors.js'; import { AnswerStatus } from '../../../../src/shared/domain/models/AnswerStatus.js'; -import { domainBuilder, expect, sinon } from '../../../test-helper.js'; +import { catchErr, domainBuilder, expect, sinon } from '../../../test-helper.js'; const locale = 'fr-fr'; @@ -20,6 +21,7 @@ describe('Unit | UseCase | simulate-flash-deterministic-assessment-scenario', fu // when const result = await simulateFlashDeterministicAssessmentScenario({ + stopAtChallenge: 3, challengeRepository, locale, pickChallenge, @@ -60,6 +62,7 @@ describe('Unit | UseCase | simulate-flash-deterministic-assessment-scenario', fu // when const result = await simulateFlashDeterministicAssessmentScenario({ + stopAtChallenge: 3, challengeRepository, locale, pickChallenge, @@ -127,6 +130,7 @@ describe('Unit | UseCase | simulate-flash-deterministic-assessment-scenario', fu // when const result = await simulateFlashDeterministicAssessmentScenario({ + stopAtChallenge: 3, challengeRepository, locale, pickChallenge, @@ -167,6 +171,7 @@ describe('Unit | UseCase | simulate-flash-deterministic-assessment-scenario', fu // when const result = await simulateFlashDeterministicAssessmentScenario({ + stopAtChallenge: 3, challengeRepository, locale, pickChallenge, @@ -198,6 +203,7 @@ describe('Unit | UseCase | simulate-flash-deterministic-assessment-scenario', fu // when const result = await simulateFlashDeterministicAssessmentScenario({ + stopAtChallenge: 3, challengeRepository, locale, pickChallenge, @@ -266,7 +272,7 @@ describe('Unit | UseCase | simulate-flash-deterministic-assessment-scenario', fu pickAnswerStatus.withArgs(sinon.match({ nextChallenge: challenge })).returns(AnswerStatus.OK); // when - const result = await simulateFlashDeterministicAssessmentScenario({ + const error = await catchErr(simulateFlashDeterministicAssessmentScenario)({ challengeRepository, locale, pickChallenge, @@ -277,15 +283,7 @@ describe('Unit | UseCase | simulate-flash-deterministic-assessment-scenario', fu }); // then - sinon.assert.match(result, [ - { - answerStatus: AnswerStatus.OK, - challenge, - errorRate: sinon.match.number, - capacity: sinon.match.number, - reward: sinon.match.number, - }, - ]); + expect(error).to.be.instanceof(AssessmentEndedError); }); }); }); From fe829b96fd85e0aad0ab0546b1ece5fb5c75f5a0 Mon Sep 17 00:00:00 2001 From: Steph0 Date: Fri, 9 Aug 2024 13:51:44 +0200 Subject: [PATCH 2/3] :recycle: api: removal of ESLint bypass --- .../domain/models/AssessmentSimulator.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/api/src/certification/flash-certification/domain/models/AssessmentSimulator.js b/api/src/certification/flash-certification/domain/models/AssessmentSimulator.js index 1f58623dae6..5a7d4cb3b50 100644 --- a/api/src/certification/flash-certification/domain/models/AssessmentSimulator.js +++ b/api/src/certification/flash-certification/domain/models/AssessmentSimulator.js @@ -9,18 +9,20 @@ export class AssessmentSimulator { const result = []; let stepIndex = 0; + let hasNextAnswer; + + do { + hasNextAnswer = false; - // eslint-disable-next-line no-constant-condition - while (true) { const simulatorStepResult = this.getStrategy(stepIndex).run({ challengesAnswers, stepIndex }); + if (simulatorStepResult) { + stepIndex = simulatorStepResult.nextStepIndex; + challengesAnswers.push(...simulatorStepResult.challengeAnswers); + result.push(...simulatorStepResult.results); - if (!simulatorStepResult) { - break; + hasNextAnswer = true; } - stepIndex = simulatorStepResult.nextStepIndex; - challengesAnswers.push(...simulatorStepResult.challengeAnswers); - result.push(...simulatorStepResult.results); - } + } while (hasNextAnswer); logger.trace({ result }, 'AssessmentSimulator result'); return result; From 161ff8cc0d4d4165c698cb877f7c828a001d878e Mon Sep 17 00:00:00 2001 From: Steph0 Date: Fri, 9 Aug 2024 18:20:36 +0200 Subject: [PATCH 3/3] :recycle: api: not masking real technical error as an HTTP 200 --- .../domain/models/FlashAssessmentAlgorithm.js | 5 ++- ...get-next-challenge-for-v3-certification.js | 2 +- ...ext-challenge-for-v3-certification_test.js | 26 ++------------- .../models/FlashAssessmentAlgorithm_test.js | 19 +++++++---- ...-challenge-for-campaign-assessment_test.js | 32 +++---------------- ...-deterministic-assessment-scenario_test.js | 4 +-- 6 files changed, 24 insertions(+), 64 deletions(-) diff --git a/api/src/certification/flash-certification/domain/models/FlashAssessmentAlgorithm.js b/api/src/certification/flash-certification/domain/models/FlashAssessmentAlgorithm.js index 6d43d870103..3c3ef1f5d2c 100644 --- a/api/src/certification/flash-certification/domain/models/FlashAssessmentAlgorithm.js +++ b/api/src/certification/flash-certification/domain/models/FlashAssessmentAlgorithm.js @@ -1,5 +1,4 @@ import { config } from '../../../../shared/config.js'; -import { AssessmentEndedError } from '../../../../shared/domain/errors.js'; import { FlashAssessmentAlgorithmChallengesBetweenCompetencesRule } from './FlashAssessmentAlgorithmChallengesBetweenCompetencesRule.js'; import { FlashAssessmentAlgorithmForcedCompetencesRule } from './FlashAssessmentAlgorithmForcedCompetencesRule.js'; import { FlashAssessmentAlgorithmNonAnsweredSkillsRule } from './FlashAssessmentAlgorithmNonAnsweredSkillsRule.js'; @@ -42,7 +41,7 @@ class FlashAssessmentAlgorithm { }) { const maximumAssessmentLength = this._configuration.maximumAssessmentLength; if (assessmentAnswers?.length > maximumAssessmentLength) { - throw new AssessmentEndedError('User answered more questions than allowed'); + throw new RangeError('User answered more questions than allowed'); } if (this.#hasAnsweredToAllChallenges({ assessmentAnswers, maximumAssessmentLength })) { @@ -58,7 +57,7 @@ class FlashAssessmentAlgorithm { const challengesAfterRulesApplication = this.#applyChallengeSelectionRules(assessmentAnswers, challenges); if (challengesAfterRulesApplication?.length === 0) { - throw new AssessmentEndedError('No eligible challenges in referential'); + throw new RangeError('No eligible challenges in referential'); } const minimalSuccessRate = this.#computeMinimalSuccessRate(assessmentAnswers.length); diff --git a/api/src/certification/session-management/domain/usecases/get-next-challenge-for-v3-certification.js b/api/src/certification/session-management/domain/usecases/get-next-challenge-for-v3-certification.js index d6bc1d20b7e..623b139a714 100644 --- a/api/src/certification/session-management/domain/usecases/get-next-challenge-for-v3-certification.js +++ b/api/src/certification/session-management/domain/usecases/get-next-challenge-for-v3-certification.js @@ -9,7 +9,7 @@ * @typedef {import('./index.js').FlashAlgorithmService} FlashAlgorithmService */ -import {AssessmentEndedError} from '../../../../shared/domain/errors.js'; +import { AssessmentEndedError } from '../../../../shared/domain/errors.js'; import { CertificationChallenge, FlashAssessmentAlgorithm } from '../../../../shared/domain/models/index.js'; /** diff --git a/api/tests/certification/session-management/unit/domain/usecases/get-next-challenge-for-v3-certification_test.js b/api/tests/certification/session-management/unit/domain/usecases/get-next-challenge-for-v3-certification_test.js index cd281f61558..8a507a5194f 100644 --- a/api/tests/certification/session-management/unit/domain/usecases/get-next-challenge-for-v3-certification_test.js +++ b/api/tests/certification/session-management/unit/domain/usecases/get-next-challenge-for-v3-certification_test.js @@ -379,6 +379,7 @@ describe('Unit | Domain | Use Cases | get-next-challenge-for-v3-certification', getCapacityAndErrorRate: sinon.stub(), }; + flashAlgorithmConfiguration = domainBuilder.buildFlashAlgorithmConfiguration({ maximumAssessmentLength: 1 }); flashAlgorithmConfigurationRepository.getMostRecentBeforeDate .withArgs(v3CertificationCourse.getStartDate()) .resolves(flashAlgorithmConfiguration); @@ -399,30 +400,6 @@ describe('Unit | Domain | Use Cases | get-next-challenge-for-v3-certification', answerRepository.findByAssessment.withArgs(assessment.id).resolves([answer]); challengeRepository.findActiveFlashCompatible.withArgs({ locale }).resolves([answeredChallenge]); - flashAlgorithmService.getCapacityAndErrorRate - .withArgs({ - allAnswers: [answer], - challenges: [answeredChallenge], - capacity: config.v3Certification.defaultCandidateCapacity, - variationPercent: undefined, - variationPercentUntil: undefined, - doubleMeasuresUntil: undefined, - }) - .returns({ - capacity: 2, - }); - - flashAlgorithmService.getPossibleNextChallenges - .withArgs({ - availableChallenges: [], - capacity: 2, - options: sinon.match.any, - }) - .returns({ - hasAssessmentEnded: true, - possibleChallenges: [], - }); - // when const error = await catchErr(getNextChallengeForV3Certification)({ answerRepository, @@ -439,6 +416,7 @@ describe('Unit | Domain | Use Cases | get-next-challenge-for-v3-certification', // then expect(error).to.be.instanceOf(AssessmentEndedError); + expect(error.message).to.equal('Evaluation terminée.'); }); }); diff --git a/api/tests/certification/shared/unit/domain/models/FlashAssessmentAlgorithm_test.js b/api/tests/certification/shared/unit/domain/models/FlashAssessmentAlgorithm_test.js index 72cd451cbd4..284515a6cf9 100644 --- a/api/tests/certification/shared/unit/domain/models/FlashAssessmentAlgorithm_test.js +++ b/api/tests/certification/shared/unit/domain/models/FlashAssessmentAlgorithm_test.js @@ -2,8 +2,7 @@ import { FlashAssessmentAlgorithm } from '../../../../../../src/certification/fl import { FlashAssessmentSuccessRateHandler } from '../../../../../../src/certification/flash-certification/domain/models/FlashAssessmentSuccessRateHandler.js'; import { FlashAssessmentAlgorithmConfiguration } from '../../../../../../src/certification/shared/domain/models/FlashAssessmentAlgorithmConfiguration.js'; import { config } from '../../../../../../src/shared/config.js'; -import { AssessmentEndedError } from '../../../../../../src/shared/domain/errors.js'; -import { domainBuilder, expect, sinon } from '../../../../../test-helper.js'; +import { catchErrSync, domainBuilder, expect, sinon } from '../../../../../test-helper.js'; const baseFlashAssessmentAlgorithmConfig = { warmUpLength: 0, @@ -30,7 +29,7 @@ describe('Unit | Domain | Models | FlashAssessmentAlgorithm | FlashAssessmentAlg describe('#getPossibleNextChallenges', function () { context('when user has answered more questions than allowed', function () { - it('should throw an AssessmentEndedError', function () { + it('should throw a RangeError', function () { // given const assessmentAnswers = [domainBuilder.buildAnswer({ id: 1 }), domainBuilder.buildAnswer({ id: 2 })]; const skill1 = domainBuilder.buildSkill({ id: 1 }); @@ -47,14 +46,22 @@ describe('Unit | Domain | Models | FlashAssessmentAlgorithm | FlashAssessmentAlg }), }); - // when / then - expect(() => + // when + const error = catchErrSync(({ assessmentAnswers, challenges, capacity }) => algorithm.getPossibleNextChallenges({ assessmentAnswers, challenges, capacity, }), - ).to.throw(AssessmentEndedError); + )({ + assessmentAnswers, + challenges, + capacity, + }); + + // then + expect(error).to.be.instanceOf(RangeError); + expect(error.message).to.equal('User answered more questions than allowed'); }); }); diff --git a/api/tests/unit/domain/usecases/get-next-challenge-for-campaign-assessment_test.js b/api/tests/unit/domain/usecases/get-next-challenge-for-campaign-assessment_test.js index 90534724fc9..20fb6d355af 100644 --- a/api/tests/unit/domain/usecases/get-next-challenge-for-campaign-assessment_test.js +++ b/api/tests/unit/domain/usecases/get-next-challenge-for-campaign-assessment_test.js @@ -196,8 +196,7 @@ describe('Unit | Domain | Use Cases | get-next-challenge-for-campaign-assessment }; const configuration = domainBuilder.buildFlashAlgorithmConfiguration({ - limitToOneQuestionPerTube: false, - enablePassageByAllCompetences: false, + maximumAssessmentLength: 1, }); flashAlgorithmConfigurationRepository.getMostRecent.resolves(configuration); @@ -214,32 +213,8 @@ describe('Unit | Domain | Use Cases | get-next-challenge-for-campaign-assessment challenges, }); - flashAlgorithmService.getCapacityAndErrorRate - .withArgs({ - challenges, - allAnswers, - capacity: config.v3Certification.defaultCandidateCapacity, - variationPercent: undefined, - variationPercentUntil: undefined, - doubleMeasuresUntil: undefined, - }) - .returns({ - capacity: 0, - errorRate: 0.5, - }); - - flashAlgorithmService.getPossibleNextChallenges - .withArgs({ - availableChallenges: [], - capacity: 0, - options: sinon.match.object, - }) - .returns({ - hasAssessmentEnded: true, - }); - // when - const getNextChallengePromise = getNextChallengeForCampaignAssessment({ + const error = await catchErr(getNextChallengeForCampaignAssessment)({ challengeRepository, answerRepository, flashAlgorithmConfigurationRepository, @@ -252,7 +227,8 @@ describe('Unit | Domain | Use Cases | get-next-challenge-for-campaign-assessment }); // then - return expect(getNextChallengePromise).to.be.rejectedWith(AssessmentEndedError); + expect(error).to.be.instanceOf(AssessmentEndedError); + expect(error.message).to.equal('Evaluation terminée.'); }); }); diff --git a/api/tests/unit/domain/usecases/simulate-flash-deterministic-assessment-scenario_test.js b/api/tests/unit/domain/usecases/simulate-flash-deterministic-assessment-scenario_test.js index 9cad4c261cd..a3e926f810d 100644 --- a/api/tests/unit/domain/usecases/simulate-flash-deterministic-assessment-scenario_test.js +++ b/api/tests/unit/domain/usecases/simulate-flash-deterministic-assessment-scenario_test.js @@ -2,7 +2,6 @@ import _ from 'lodash'; import { simulateFlashDeterministicAssessmentScenario } from '../../../../src/certification/flash-certification/domain/usecases/simulate-flash-deterministic-assessment-scenario.js'; import { config } from '../../../../src/shared/config.js'; -import { AssessmentEndedError } from '../../../../src/shared/domain/errors.js'; import { AnswerStatus } from '../../../../src/shared/domain/models/AnswerStatus.js'; import { catchErr, domainBuilder, expect, sinon } from '../../../test-helper.js'; @@ -283,7 +282,8 @@ describe('Unit | UseCase | simulate-flash-deterministic-assessment-scenario', fu }); // then - expect(error).to.be.instanceof(AssessmentEndedError); + expect(error).to.be.instanceof(RangeError); + expect(error.message).to.equal('No eligible challenges in referential'); }); }); });