diff --git a/api/src/certification/shared/application/http-error-mapper-configuration.js b/api/src/certification/shared/application/http-error-mapper-configuration.js index f74ecb62ecf..b51ce585f6d 100644 --- a/api/src/certification/shared/application/http-error-mapper-configuration.js +++ b/api/src/certification/shared/application/http-error-mapper-configuration.js @@ -1,3 +1,4 @@ +import { parcoursupDomainErrorMappingConfiguration } from '../../../parcoursup/application/http-error-mapper-configuration.js'; import { HttpErrors } from '../../../shared/application/http-errors.js'; import { DomainErrorMappingConfiguration } from '../../../shared/application/models/domain-error-mapping-configuration.js'; import { configurationDomainErrorMappingConfiguration } from '../../configuration/application/http-error-mapper-configuration.js'; @@ -24,5 +25,6 @@ certificationDomainErrorMappingConfiguration.push( ...enrolmentDomainErrorMappingConfiguration, ...sessionDomainErrorMappingConfiguration, ...configurationDomainErrorMappingConfiguration, + ...parcoursupDomainErrorMappingConfiguration, ); export { certificationDomainErrorMappingConfiguration }; diff --git a/api/src/parcoursup/application/certification-controller.js b/api/src/parcoursup/application/certification-controller.js index b7757168076..fb640033f0e 100644 --- a/api/src/parcoursup/application/certification-controller.js +++ b/api/src/parcoursup/application/certification-controller.js @@ -1,7 +1,16 @@ import { usecases } from '../domain/usecases/index.js'; const getCertificationResult = async function (request) { - return usecases.getCertificationResult(request.payload); + const { ine, organizationUai, lastName, firstName, birthdate, verificationCode } = request.payload; + + return usecases.getCertificationResult({ + ine, + organizationUai, + lastName, + firstName, + birthdate, + verificationCode, + }); }; export const certificationController = { diff --git a/api/src/parcoursup/application/certification-route.js b/api/src/parcoursup/application/certification-route.js index a2a6709f03d..00bd46d714c 100644 --- a/api/src/parcoursup/application/certification-route.js +++ b/api/src/parcoursup/application/certification-route.js @@ -80,6 +80,7 @@ const register = async function (server) { 401: responseObjectErrorDoc, 403: responseObjectErrorDoc, 404: responseObjectErrorDoc, + 409: responseObjectErrorDoc, }, }, }, diff --git a/api/src/parcoursup/application/http-error-mapper-configuration.js b/api/src/parcoursup/application/http-error-mapper-configuration.js new file mode 100644 index 00000000000..10e54649fe9 --- /dev/null +++ b/api/src/parcoursup/application/http-error-mapper-configuration.js @@ -0,0 +1,10 @@ +import { HttpErrors } from '../../shared/application/http-errors.js'; +import { DomainErrorMappingConfiguration } from '../../shared/application/models/domain-error-mapping-configuration.js'; +import { MoreThanOneMatchingCertificationError } from '../domain/errors.js'; + +export const parcoursupDomainErrorMappingConfiguration = [ + { + name: MoreThanOneMatchingCertificationError.name, + httpErrorFn: (error) => new HttpErrors.ConflictError(error.message), + }, +].map((domainErrorMappingConfiguration) => new DomainErrorMappingConfiguration(domainErrorMappingConfiguration)); diff --git a/api/src/parcoursup/domain/errors.js b/api/src/parcoursup/domain/errors.js new file mode 100644 index 00000000000..ea1d2a2a84e --- /dev/null +++ b/api/src/parcoursup/domain/errors.js @@ -0,0 +1,7 @@ +import { DomainError } from '../../shared/domain/errors.js'; + +export class MoreThanOneMatchingCertificationError extends DomainError { + constructor(message = 'More than one candidate found for current search parameters') { + super(message); + } +} diff --git a/api/src/parcoursup/domain/read-models/CertificationResult.js b/api/src/parcoursup/domain/read-models/CertificationResult.js index 09ad87dbebf..d16fdead1fe 100644 --- a/api/src/parcoursup/domain/read-models/CertificationResult.js +++ b/api/src/parcoursup/domain/read-models/CertificationResult.js @@ -1,4 +1,20 @@ -class CertificationResult { +/** + * @typedef {import('./Competence.js').Competence} Competence + */ + +export class CertificationResult { + /** + * @param {Object} props + * @param {string} props.[ine] + * @param {string} props.[organizationUai] + * @param {string} props.lastName + * @param {string} props.firstName + * @param {string} props.birthdate + * @param {string} props.status + * @param {string} props.pixScore + * @param {Date} props.certificationDate + * @param {Array} props.competences + */ constructor({ ine, organizationUai, @@ -21,5 +37,3 @@ class CertificationResult { this.competences = competences; } } - -export { CertificationResult }; diff --git a/api/src/parcoursup/domain/read-models/Competence.js b/api/src/parcoursup/domain/read-models/Competence.js new file mode 100644 index 00000000000..2c61ef9707a --- /dev/null +++ b/api/src/parcoursup/domain/read-models/Competence.js @@ -0,0 +1,15 @@ +export class Competence { + /** + * @param {Object} props + * @param {string} props.code + * @param {string} props.name + * @param {string} props.areaName + * @param {string} props.level + */ + constructor({ code, name, areaName, level }) { + this.code = code; + this.name = name; + this.areaName = areaName; + this.level = level; + } +} diff --git a/api/src/parcoursup/domain/usecases/get-certification-result.js b/api/src/parcoursup/domain/usecases/get-certification-result.js index 643924d715d..0e6da112fea 100644 --- a/api/src/parcoursup/domain/usecases/get-certification-result.js +++ b/api/src/parcoursup/domain/usecases/get-certification-result.js @@ -1,7 +1,11 @@ /** * @typedef {import('../../domain/usecases/index.js').CertificationRepository} CertificationRepository + * @typedef {import('../read-models/CertificationResult.js').CertificationResult} CertificationResult + * @typedef {import('../../../shared/domain/errors.js').NotFoundError} NotFoundError */ +import { MoreThanOneMatchingCertificationError } from '../errors.js'; + /** * @param {Object} params * @param {string} params.ine @@ -11,8 +15,12 @@ * @param {string} params.birthdate - Format YYYY-MM-DD * @param {string} params.verificationCode * @param {CertificationRepository} params.certificationRepository + * + * @returns {CertificationResult} matching candidate certification result + * @throws {MoreThanOneMatchingCertificationError} in some cases (INE for example) there might be duplicates + * @throws {NotFoundError} if no certification exists for this candidate **/ -export const getCertificationResult = function ({ +export const getCertificationResult = async ({ ine, organizationUai, lastName, @@ -20,16 +28,25 @@ export const getCertificationResult = function ({ birthdate, verificationCode, certificationRepository, -}) { +}) => { + let certifications = []; + if (ine) { - return certificationRepository.getByINE({ ine }); + certifications = await certificationRepository.getByINE({ ine }); + } else if (organizationUai) { + certifications = await certificationRepository.getByOrganizationUAI({ + organizationUai, + lastName, + firstName, + birthdate, + }); + } else if (verificationCode) { + certifications = await certificationRepository.getByVerificationCode({ verificationCode }); } - if (organizationUai) { - return certificationRepository.getByOrganizationUAI({ organizationUai, lastName, firstName, birthdate }); + if (certifications.length !== 1) { + throw new MoreThanOneMatchingCertificationError(); } - if (verificationCode) { - return certificationRepository.getByVerificationCode({ verificationCode }); - } + return certifications.shift(); }; diff --git a/api/src/parcoursup/infrastructure/repositories/certification-repository.js b/api/src/parcoursup/infrastructure/repositories/certification-repository.js index c17c52ac4dd..2076c750b87 100644 --- a/api/src/parcoursup/infrastructure/repositories/certification-repository.js +++ b/api/src/parcoursup/infrastructure/repositories/certification-repository.js @@ -1,6 +1,7 @@ import { datamartKnex } from '../../../../db/knex-database-connection.js'; import { NotFoundError } from '../../../shared/domain/errors.js'; import { CertificationResult } from '../../domain/read-models/CertificationResult.js'; +import { Competence } from '../../domain/read-models/Competence.js'; const getByINE = async ({ ine }) => { return _getBySearchParams({ @@ -19,7 +20,26 @@ const getByOrganizationUAI = async ({ organizationUai, lastName, firstName, birt const _getBySearchParams = async (searchParams) => { const certificationResultDto = await datamartKnex('data_export_parcoursup_certif_result') - .select( + .select({ + national_student_id: 'national_student_id', + organization_uai: 'organization_uai', + last_name: 'last_name', + first_name: 'first_name', + birthdate: 'birthdate', + status: 'status', + pix_score: 'pix_score', + certification_date: 'certification_date', + competences: datamartKnex.raw( + `json_agg(json_build_object( + 'competence_code', "competence_code", + 'competence_name', "competence_name", + 'area_name', "area_name", + 'competence_level', "competence_level" + ))`, + ), + }) + .where(searchParams) + .groupBy( 'national_student_id', 'organization_uai', 'last_name', @@ -28,12 +48,8 @@ const _getBySearchParams = async (searchParams) => { 'status', 'pix_score', 'certification_date', - 'competence_code', - 'competence_name', - 'area_name', - 'competence_level', - ) - .where(searchParams); + ); + if (!certificationResultDto.length) { throw new NotFoundError('No certifications found for given search parameters'); } @@ -43,21 +59,27 @@ const _getBySearchParams = async (searchParams) => { const getByVerificationCode = async ({ verificationCode }) => { const certificationResultDto = await datamartKnex('data_export_parcoursup_certif_result_code_validation') - .select( - 'last_name', - 'first_name', - 'birthdate', - 'status', - 'pix_score', - 'certification_date', - 'competence_code', - 'competence_name', - 'area_name', - 'competence_level', - ) + .select({ + last_name: 'last_name', + first_name: 'first_name', + birthdate: 'birthdate', + status: 'status', + pix_score: 'pix_score', + certification_date: 'certification_date', + competences: datamartKnex.raw( + `json_agg(json_build_object( + 'competence_code', "competence_code", + 'competence_name', "competence_name", + 'area_name', "area_name", + 'competence_level', "competence_level" + ))`, + ), + }) .where({ certification_code_verification: verificationCode, - }); + }) + .groupBy('last_name', 'first_name', 'birthdate', 'status', 'pix_score', 'certification_date'); + if (!certificationResultDto.length) { throw new NotFoundError('No certifications found for given search parameters'); } @@ -65,24 +87,31 @@ const getByVerificationCode = async ({ verificationCode }) => { return _toDomain(certificationResultDto); }; +/** + * @returns {Array} + */ const _toDomain = (certificationResultDto) => { - return new CertificationResult({ - ine: certificationResultDto[0].national_student_id, - organizationUai: certificationResultDto[0].organization_uai, - lastName: certificationResultDto[0].last_name, - firstName: certificationResultDto[0].first_name, - birthdate: certificationResultDto[0].birthdate, - status: certificationResultDto[0].status, - pixScore: certificationResultDto[0].pix_score, - certificationDate: certificationResultDto[0].certification_date, - competences: certificationResultDto.map((certificationResultDto) => { - return { - code: certificationResultDto.competence_code, - name: certificationResultDto.competence_name, - areaName: certificationResultDto.area_name, - level: certificationResultDto.competence_level, - }; - }), + return certificationResultDto.map((certificationResult) => { + const competences = certificationResult.competences.map((competence) => { + return new Competence({ + code: competence.competence_code, + name: competence.competence_name, + areaName: competence.area_name, + level: competence.competence_level, + }); + }); + + return new CertificationResult({ + ine: certificationResult.national_student_id, + organizationUai: certificationResult.organization_uai, + lastName: certificationResult.last_name, + firstName: certificationResult.first_name, + birthdate: certificationResult.birthdate, + status: certificationResult.status, + pixScore: certificationResult.pix_score, + certificationDate: certificationResult.certification_date, + competences, + }); }); }; diff --git a/api/tests/parcoursup/acceptance/application/certification-route_test.js b/api/tests/parcoursup/acceptance/application/certification-route_test.js index 75ec7ab039c..0ad293880da 100644 --- a/api/tests/parcoursup/acceptance/application/certification-route_test.js +++ b/api/tests/parcoursup/acceptance/application/certification-route_test.js @@ -15,7 +15,8 @@ describe('Parcoursup | Acceptance | Application | certification-route', function PARCOURSUP_CLIENT_ID, PARCOURSUP_SCOPE, PARCOURSUP_SOURCE, - expectedCertification; + expectedCertification, + certificationResultData; beforeEach(async function () { server = await createServer(); @@ -30,7 +31,7 @@ describe('Parcoursup | Acceptance | Application | certification-route', function firstName = 'PRENOM-ELEVE'; birthdate = '2000-01-01'; - const certificationResultData = { + certificationResultData = { nationalStudentId: ine, organizationUai, lastName, diff --git a/api/tests/parcoursup/integration/repositories/certification-repository_test.js b/api/tests/parcoursup/integration/repositories/certification-repository_test.js index 483676ae89e..400fca848fc 100644 --- a/api/tests/parcoursup/integration/repositories/certification-repository_test.js +++ b/api/tests/parcoursup/integration/repositories/certification-repository_test.js @@ -35,7 +35,7 @@ describe('Parcoursup | Infrastructure | Integration | Repositories | certificati await datamartBuilder.commit(); // when - const result = await certificationRepository.getByINE({ ine }); + const results = await certificationRepository.getByINE({ ine }); // then const expectedCertification = domainBuilder.parcoursup.buildCertificationResult({ @@ -48,21 +48,21 @@ describe('Parcoursup | Infrastructure | Integration | Repositories | certificati pixScore: 327, certificationDate: new Date('2024-11-22T09:39:54Z'), competences: [ - { + domainBuilder.parcoursup.buildCompetence({ code: '1.1', name: 'Mener une recherche et une veille d’information', areaName: 'Informations et données', level: 3, - }, - { + }), + domainBuilder.parcoursup.buildCompetence({ code: '1.2', name: 'Gérer des données', areaName: 'Informations et données', level: 5, - }, + }), ], }); - expect(result).to.deep.equal(expectedCertification); + expect(results).to.deep.equal([expectedCertification]); }); }); @@ -116,7 +116,7 @@ describe('Parcoursup | Infrastructure | Integration | Repositories | certificati await datamartBuilder.commit(); // when - const result = await certificationRepository.getByOrganizationUAI({ + const results = await certificationRepository.getByOrganizationUAI({ organizationUai, lastName, firstName, @@ -134,21 +134,21 @@ describe('Parcoursup | Infrastructure | Integration | Repositories | certificati pixScore: 327, certificationDate: new Date('2024-11-22T09:39:54Z'), competences: [ - { + domainBuilder.parcoursup.buildCompetence({ code: '1.1', name: 'Mener une recherche et une veille d’information', areaName: 'Informations et données', level: 3, - }, - { + }), + domainBuilder.parcoursup.buildCompetence({ code: '1.2', name: 'Gérer des données', areaName: 'Informations et données', level: 5, - }, + }), ], }); - expect(result).to.deep.equal(expectedCertification); + expect(results).to.deep.equal([expectedCertification]); }); }); @@ -209,7 +209,7 @@ describe('Parcoursup | Infrastructure | Integration | Repositories | certificati await datamartBuilder.commit(); // when - const result = await certificationRepository.getByVerificationCode({ + const results = await certificationRepository.getByVerificationCode({ verificationCode, }); @@ -222,21 +222,21 @@ describe('Parcoursup | Infrastructure | Integration | Repositories | certificati pixScore: 327, certificationDate: new Date('2024-11-22T09:39:54Z'), competences: [ - { + domainBuilder.parcoursup.buildCompetence({ code: '1.1', name: 'Mener une recherche et une veille d’information', areaName: 'Informations et données', level: 3, - }, - { + }), + domainBuilder.parcoursup.buildCompetence({ code: '1.2', name: 'Gérer des données', areaName: 'Informations et données', level: 5, - }, + }), ], }); - expect(result).to.deep.equal(expectedCertification); + expect(results).to.deep.equal([expectedCertification]); }); }); diff --git a/api/tests/parcoursup/unit/application/certification-route_test.js b/api/tests/parcoursup/unit/application/certification-route_test.js index 320c207ee4d..46b7ab9755a 100644 --- a/api/tests/parcoursup/unit/application/certification-route_test.js +++ b/api/tests/parcoursup/unit/application/certification-route_test.js @@ -1,5 +1,6 @@ import { certificationController } from '../../../../src/parcoursup/application/certification-controller.js'; import * as moduleUnderTest from '../../../../src/parcoursup/application/certification-route.js'; +import { MoreThanOneMatchingCertificationError } from '../../../../src/parcoursup/domain/errors.js'; import { expect, generateValidRequestAuthorizationHeaderForApplication, @@ -8,11 +9,11 @@ import { } from '../../../test-helper.js'; describe('Parcoursup | Unit | Application | Routes | Certification', function () { - let url, method, headers, httpTestServer; + let url, method, headers, httpTestServer, controllerStub; beforeEach(async function () { url = '/api/application/parcoursup/certification/search'; - sinon.stub(certificationController, 'getCertificationResult').callsFake((request, h) => h.response().code(200)); + controllerStub = sinon.stub(certificationController, 'getCertificationResult'); httpTestServer = new HttpTestServer(); httpTestServer.setupAuthentication(); @@ -33,45 +34,51 @@ describe('Parcoursup | Unit | Application | Routes | Certification', function () }); describe('POST /parcoursup/certification/search', function () { - it('should return 200 with a valid ine params in body', async function () { - //given - const payload = { - ine: '123456789OK', - }; - // when - const response = await httpTestServer.request(method, url, payload, null, headers); + context('return a result', function () { + beforeEach(function () { + controllerStub.callsFake((request, h) => h.response().code(200)); + }); - // then - expect(response.statusCode).to.equal(200); - }); + it('with a valid ine params in body', async function () { + //given + const payload = { + ine: '123456789OK', + }; + // when + const response = await httpTestServer.request(method, url, payload, null, headers); - it('should return 200 with with valid organizationUai, lastName, firstName and birthdate in body params', async function () { - //given - const payload = { - organizationUai: '1234567A', - lastName: 'LEPONGE', - firstName: 'BOB', - birthdate: '2000-01-01', - }; + // then + expect(response.statusCode).to.equal(200); + }); - // when - const response = await httpTestServer.request(method, url, payload, null, headers); + it('with valid organizationUai, lastName, firstName and birthdate in body params', async function () { + //given + const payload = { + organizationUai: '1234567A', + lastName: 'LEPONGE', + firstName: 'BOB', + birthdate: '2000-01-01', + }; - // then - expect(response.statusCode).to.equal(200); - }); + // when + const response = await httpTestServer.request(method, url, payload, null, headers); - it('should return 200 with a valide verificationCode params in body', async function () { - //given - const payload = { - verificationCode: 'P-1234567A', - }; + // then + expect(response.statusCode).to.equal(200); + }); - // when - const response = await httpTestServer.request(method, url, payload, null, headers); + it('with a valid verificationCode params in body', async function () { + //given + const payload = { + verificationCode: 'P-1234567A', + }; - // then - expect(response.statusCode).to.equal(200); + // when + const response = await httpTestServer.request(method, url, payload, null, headers); + + // then + expect(response.statusCode).to.equal(200); + }); }); context('return 400 error', function () { @@ -186,6 +193,24 @@ describe('Parcoursup | Unit | Application | Routes | Certification', function () }); }); + context('return 409 error', function () { + it('with duplicated certification results', async function () { + // given + const payload = { + verificationCode: 'P-1234567A', + }; + controllerStub.throws(function () { + return new MoreThanOneMatchingCertificationError(); + }); + + // when + const response = await httpTestServer.request(method, url, payload, null, headers); + + // then + expect(response.statusCode).to.equal(409); + }); + }); + it('should return 403 with a wrong scope', async function () { //given const PARCOURSUP_CLIENT_ID = 'test-parcoursupClientId'; diff --git a/api/tests/parcoursup/unit/domain/usecases/get-certification-result_test.js b/api/tests/parcoursup/unit/domain/usecases/get-certification-result_test.js index 7d044ead69d..4fe61171418 100644 --- a/api/tests/parcoursup/unit/domain/usecases/get-certification-result_test.js +++ b/api/tests/parcoursup/unit/domain/usecases/get-certification-result_test.js @@ -1,8 +1,28 @@ +import { MoreThanOneMatchingCertificationError } from '../../../../../src/parcoursup/domain/errors.js'; import { getCertificationResult } from '../../../../../src/parcoursup/domain/usecases/get-certification-result.js'; -import { domainBuilder, expect, sinon } from '../../../../test-helper.js'; +import { catchErr, domainBuilder, expect, sinon } from '../../../../test-helper.js'; describe('Parcoursup | Unit | Domain | UseCase | getCertificationResult', function () { describe('#getCertificationResult', function () { + it('should not allow more than one result', async function () { + // given + const ine = '1234'; + const certificationRepository = { + getByINE: sinon.stub(), + }; + + const oneCertification = domainBuilder.parcoursup.buildCertificationResult({ ine }); + const duplicatedCertification = domainBuilder.parcoursup.buildCertificationResult({ ine }); + certificationRepository.getByINE.withArgs({ ine }).resolves([oneCertification, duplicatedCertification]); + + // when + const error = await catchErr(getCertificationResult)({ ine, certificationRepository }); + + // then + expect(error).to.be.instanceOf(MoreThanOneMatchingCertificationError); + expect(error.message).to.equal('More than one candidate found for current search parameters'); + }); + context('with INE', function () { it('returns matching certification', async function () { // given @@ -12,7 +32,7 @@ describe('Parcoursup | Unit | Domain | UseCase | getCertificationResult', functi }; const expectedCertification = domainBuilder.parcoursup.buildCertificationResult({ ine }); - certificationRepository.getByINE.withArgs({ ine }).resolves(expectedCertification); + certificationRepository.getByINE.withArgs({ ine }).resolves([expectedCertification]); // when const certification = await getCertificationResult({ ine, certificationRepository }); @@ -46,7 +66,7 @@ describe('Parcoursup | Unit | Domain | UseCase | getCertificationResult', functi firstName, birthdate, }) - .resolves(expectedCertification); + .resolves([expectedCertification]); // when const certification = await getCertificationResult({ @@ -77,7 +97,7 @@ describe('Parcoursup | Unit | Domain | UseCase | getCertificationResult', functi .withArgs({ verificationCode, }) - .resolves(expectedCertification); + .resolves([expectedCertification]); // when const certification = await getCertificationResult({ diff --git a/api/tests/tooling/domain-builder/factory/index.js b/api/tests/tooling/domain-builder/factory/index.js index f70f63d3d96..caa671c7e35 100644 --- a/api/tests/tooling/domain-builder/factory/index.js +++ b/api/tests/tooling/domain-builder/factory/index.js @@ -200,6 +200,7 @@ import { buildCompetenceForScoring } from './certification/shared/build-competen import { buildJuryComment } from './certification/shared/build-jury-comment.js'; import { buildV3CertificationScoring } from './certification/shared/build-v3-certification-scoring.js'; import { buildCertificationResult as parcoursupCertificationResult } from './parcoursup/build-certification-result.js'; +import { buildCompetence as parcoursupCompetence } from './parcoursup/build-competence.js'; import { buildCampaign as boundedContextCampaignBuildCampaign } from './prescription/campaign/build-campaign.js'; import { buildCampaignParticipation as boundedContextCampaignParticipationBuildCampaignParticipation } from './prescription/campaign-participation/build-campaign-participation.js'; import { buildStageCollection as buildStageCollectionForTargetProfileManagement } from './target-profile-management/build-stage-collection.js'; @@ -262,6 +263,7 @@ const certification = { const parcoursup = { buildCertificationResult: parcoursupCertificationResult, + buildCompetence: parcoursupCompetence, }; const prescription = { diff --git a/api/tests/tooling/domain-builder/factory/parcoursup/build-competence.js b/api/tests/tooling/domain-builder/factory/parcoursup/build-competence.js new file mode 100644 index 00000000000..adad19bf7a5 --- /dev/null +++ b/api/tests/tooling/domain-builder/factory/parcoursup/build-competence.js @@ -0,0 +1,5 @@ +import { Competence } from '../../../../../src/parcoursup/domain/read-models/Competence.js'; + +export const buildCompetence = function ({ code, name, areaName, level }) { + return new Competence({ code, name, areaName, level }); +};