diff --git a/api/lib/infrastructure/repositories/learning-content-repository.js b/api/lib/infrastructure/repositories/learning-content-repository.js deleted file mode 100644 index 82851af1ffa..00000000000 --- a/api/lib/infrastructure/repositories/learning-content-repository.js +++ /dev/null @@ -1,144 +0,0 @@ -import _ from 'lodash'; - -import { knex } from '../../../db/knex-database-connection.js'; -import * as campaignRepository from '../../../src/prescription/campaign/infrastructure/repositories/campaign-repository.js'; -import { NoSkillsInCampaignError, NotFoundError } from '../../../src/shared/domain/errors.js'; -import { CampaignLearningContent } from '../../../src/shared/domain/models/CampaignLearningContent.js'; -import { LearningContent } from '../../../src/shared/domain/models/LearningContent.js'; -import * as areaRepository from '../../../src/shared/infrastructure/repositories/area-repository.js'; -import * as competenceRepository from '../../../src/shared/infrastructure/repositories/competence-repository.js'; -import * as frameworkRepository from '../../../src/shared/infrastructure/repositories/framework-repository.js'; -import * as skillRepository from '../../../src/shared/infrastructure/repositories/skill-repository.js'; -import * as thematicRepository from '../../../src/shared/infrastructure/repositories/thematic-repository.js'; -import * as tubeRepository from '../../../src/shared/infrastructure/repositories/tube-repository.js'; -import * as learningContentConversionService from '../../domain/services/learning-content/learning-content-conversion-service.js'; - -async function findByCampaignId(campaignId, locale) { - const skills = await campaignRepository.findSkills({ campaignId }); - - const frameworks = await _getLearningContentBySkillIds(skills, locale); - - return new CampaignLearningContent(frameworks); -} - -async function findByTargetProfileId(targetProfileId, locale) { - const cappedTubesDTO = await knex('target-profile_tubes') - .select({ - id: 'tubeId', - level: 'level', - }) - .where({ targetProfileId }); - - if (cappedTubesDTO.length === 0) { - throw new NotFoundError("Le profil cible n'existe pas"); - } - - const frameworks = await _getLearningContentByCappedTubes(cappedTubesDTO, locale); - return new LearningContent(frameworks); -} - -async function findByFrameworkNames({ frameworkNames, locale }) { - const baseFrameworks = []; - for (const frameworkName of frameworkNames) { - baseFrameworks.push(await frameworkRepository.getByName(frameworkName)); - } - - const frameworks = await _getLearningContentByFrameworks(baseFrameworks, locale); - return new LearningContent(frameworks); -} - -async function _getLearningContentBySkillIds(skills, locale) { - if (_.isEmpty(skills)) { - throw new NoSkillsInCampaignError(); - } - const tubeIds = _.uniq(skills.map((skill) => skill.tubeId)); - const tubes = await tubeRepository.findByRecordIds(tubeIds, locale); - - tubes.forEach((tube) => { - tube.skills = skills.filter((skill) => { - return skill.tubeId === tube.id; - }); - }); - - return _getLearningContentByTubes(tubes, locale); -} - -async function _getLearningContentByCappedTubes(cappedTubesDTO, locale) { - const skills = await learningContentConversionService.findActiveSkillsForCappedTubes(cappedTubesDTO); - - const tubes = await tubeRepository.findByRecordIds( - cappedTubesDTO.map((dto) => dto.id), - locale, - ); - - tubes.forEach((tube) => { - tube.skills = skills.filter((skill) => { - return skill.tubeId === tube.id; - }); - }); - - return _getLearningContentByTubes(tubes, locale); -} - -async function _getLearningContentByTubes(tubes, locale) { - const thematicIds = _.uniq(tubes.map((tube) => tube.thematicId)); - const thematics = await thematicRepository.findByRecordIds(thematicIds, locale); - thematics.forEach((thematic) => { - thematic.tubes = tubes.filter((tube) => tube.thematicId === thematic.id); - }); - - const competenceIds = _.uniq(tubes.map((tube) => tube.competenceId)); - const competences = await competenceRepository.findByRecordIds({ competenceIds, locale }); - - competences.forEach((competence) => { - competence.tubes = tubes.filter((tube) => { - return tube.competenceId === competence.id; - }); - competence.thematics = thematics.filter((thematic) => { - return thematic.competenceId === competence.id; - }); - }); - - const allAreaIds = _.map(competences, (competence) => competence.areaId); - const uniqAreaIds = _.uniq(allAreaIds, 'id'); - const areas = await areaRepository.findByRecordIds({ areaIds: uniqAreaIds, locale }); - for (const area of areas) { - area.competences = competences.filter((competence) => { - return competence.areaId === area.id; - }); - } - - const frameworkIds = _.uniq(areas.map((area) => area.frameworkId)); - const frameworks = await frameworkRepository.findByRecordIds(frameworkIds); - for (const framework of frameworks) { - framework.areas = areas.filter((area) => { - return area.frameworkId === framework.id; - }); - } - - return frameworks; -} - -async function _getLearningContentByFrameworks(frameworks, locale) { - for (const framework of frameworks) { - framework.areas = await areaRepository.findByFrameworkId({ frameworkId: framework.id, locale }); - for (const area of framework.areas) { - area.competences = await competenceRepository.findByAreaId({ areaId: area.id, locale }); - for (const competence of area.competences) { - competence.thematics = await thematicRepository.findByCompetenceIds([competence.id], locale); - for (const thematic of competence.thematics) { - const tubes = await tubeRepository.findActiveByRecordIds(thematic.tubeIds, locale); - thematic.tubes = tubes; - competence.tubes.push(...tubes); - for (const tube of thematic.tubes) { - tube.skills = await skillRepository.findActiveByTubeId(tube.id); - } - } - } - } - } - - return frameworks; -} - -export { findByCampaignId, findByFrameworkNames, findByTargetProfileId }; diff --git a/api/src/certification/evaluation/domain/services/certification-challenges-service.js b/api/src/certification/evaluation/domain/services/certification-challenges-service.js index ee313e786d6..a6fbb49e6f7 100644 --- a/api/src/certification/evaluation/domain/services/certification-challenges-service.js +++ b/api/src/certification/evaluation/domain/services/certification-challenges-service.js @@ -1,6 +1,6 @@ import _ from 'lodash'; -import * as learningContentRepository from '../../../../../lib/infrastructure/repositories/learning-content-repository.js'; +import * as learningContentRepository from '../../../../shared/infrastructure/repositories/learning-content-repository.js'; import { MAX_CHALLENGES_PER_AREA_FOR_CERTIFICATION_PLUS, MAX_CHALLENGES_PER_COMPETENCE_FOR_CERTIFICATION, diff --git a/api/src/devcomp/infrastructure/repositories/tutorial-repository.js b/api/src/devcomp/infrastructure/repositories/tutorial-repository.js index 4c2185ea1a9..2cb779df2f1 100644 --- a/api/src/devcomp/infrastructure/repositories/tutorial-repository.js +++ b/api/src/devcomp/infrastructure/repositories/tutorial-repository.js @@ -3,7 +3,7 @@ import _ from 'lodash'; import { LOCALE } from '../../../shared/domain/constants.js'; import { NotFoundError } from '../../../shared/domain/errors.js'; import * as knowledgeElementRepository from '../../../shared/infrastructure/repositories/knowledge-element-repository.js'; -import { LearningContentRepository } from '../../../shared/infrastructure/repositories/learning-content-repository.js'; +import { LearningContentDatasource } from '../../../shared/infrastructure/repositories/learning-content-datasource.js'; import * as skillRepository from '../../../shared/infrastructure/repositories/skill-repository.js'; import * as paginateModule from '../../../shared/infrastructure/utils/paginate.js'; import { Tutorial } from '../../domain/models/Tutorial.js'; @@ -161,12 +161,12 @@ export function clearCache(id) { return getInstance().clearCache(id); } -/** @type {LearningContentRepository} */ +/** @type {LearningContentDatasource} */ let instance; function getInstance() { if (!instance) { - instance = new LearningContentRepository({ tableName: TABLE_NAME }); + instance = new LearningContentDatasource({ tableName: TABLE_NAME }); } return instance; } diff --git a/api/src/prescription/campaign-participation/domain/usecases/index.js b/api/src/prescription/campaign-participation/domain/usecases/index.js index 39309ffacd3..2295d8e430c 100644 --- a/api/src/prescription/campaign-participation/domain/usecases/index.js +++ b/api/src/prescription/campaign-participation/domain/usecases/index.js @@ -1,7 +1,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import * as learningContentRepository from '../../../../../lib/infrastructure/repositories/learning-content-repository.js'; +import * as learningContentRepository from '../../../../shared/infrastructure/repositories/learning-content-repository.js'; import * as stageCollectionRepository from '../../../../../lib/infrastructure/repositories/user-campaign-results/stage-collection-repository.js'; import * as tutorialRepository from '../../../../devcomp/infrastructure/repositories/tutorial-repository.js'; import * as compareStagesAndAcquiredStages from '../../../../evaluation/domain/services/stages/stage-and-stage-acquisition-comparison-service.js'; @@ -61,7 +61,7 @@ import * as poleEmploiSendingRepository from '../../infrastructure/repositories/ * @typedef { import ('../../../../evaluation/infrastructure/repositories/competence-evaluation-repository.js')} CompetenceEvaluationRepository * @typedef { import ('../../../../shared/infrastructure/repositories/competence-repository.js')} CompetenceRepository * @typedef { import ('../../../../../lib/infrastructure/repositories/knowledge-element-repository.js')} KnowledgeElementRepository - * @typedef { import ('../../../../../lib/infrastructure/repositories/learning-content-repository.js')} LearningContentRepository + * @typedef { import ('../../../../shared/infrastructure/repositories/learning-content-repository.js')} LearningContentRepository * @typedef { import ('../../../../../lib/infrastructure/repositories/organization-learner-repository.js')} OrganizationLearnerRepository * @typedef { import ('../../infrastructure/repositories/participant-result-repository.js')} ParticipantResultRepository * @typedef { import ('../../infrastructure/repositories/participant-results-shared-repository.js')} participantResultsSharedRepository diff --git a/api/src/prescription/campaign-participation/infrastructure/repositories/campaign-assessment-participation-result-repository.js b/api/src/prescription/campaign-participation/infrastructure/repositories/campaign-assessment-participation-result-repository.js index 04b0154479e..e1e4db0d358 100644 --- a/api/src/prescription/campaign-participation/infrastructure/repositories/campaign-assessment-participation-result-repository.js +++ b/api/src/prescription/campaign-participation/infrastructure/repositories/campaign-assessment-participation-result-repository.js @@ -1,5 +1,5 @@ import { knex } from '../../../../../db/knex-database-connection.js'; -import * as learningContentRepository from '../../../../../lib/infrastructure/repositories/learning-content-repository.js'; +import * as learningContentRepository from '../../../../shared/infrastructure/repositories/learning-content-repository.js'; import { NotFoundError } from '../../../../shared/domain/errors.js'; import * as knowledgeElementRepository from '../../../../shared/infrastructure/repositories/knowledge-element-repository.js'; import { CampaignAssessmentParticipationResult } from '../../domain/models/CampaignAssessmentParticipationResult.js'; diff --git a/api/src/prescription/campaign/domain/usecases/index.js b/api/src/prescription/campaign/domain/usecases/index.js index c3c5ef1eb78..f5548fb492b 100644 --- a/api/src/prescription/campaign/domain/usecases/index.js +++ b/api/src/prescription/campaign/domain/usecases/index.js @@ -1,7 +1,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import * as learningContentRepository from '../../../../../lib/infrastructure/repositories/learning-content-repository.js'; +import * as learningContentRepository from '../../../../shared/infrastructure/repositories/learning-content-repository.js'; import * as stageCollectionRepository from '../../../../../lib/infrastructure/repositories/user-campaign-results/stage-collection-repository.js'; import * as campaignRepository from '../../../../../src/prescription/campaign/infrastructure/repositories/campaign-repository.js'; import * as tutorialRepository from '../../../../devcomp/infrastructure/repositories/tutorial-repository.js'; @@ -45,7 +45,7 @@ import * as campaignUpdateValidator from '../validators/campaign-update-validato * @typedef { import ('../../../../devcomp/infrastructure/repositories/tutorial-repository.js')} TutorialRepository * @typedef { import ('../../../../../lib/infrastructure/repositories/knowledge-element-repository.js')} KnowledgeElementRepository * @typedef { import ('../../../../../lib/infrastructure/repositories/knowledge-element-snapshot-repository.js')} KnowledgeElementSnapshotRepository - * @typedef { import ('../../../../../lib/infrastructure/repositories/learning-content-repository.js')} LearningContentRepository + * @typedef { import ('../../../../shared/infrastructure/repositories/learning-content-repository.js')} LearningContentRepository * @typedef { import ('../../../../team/infrastructure/repositories/membership-repository.js')} MembershipRepository * @typedef { import ('../../../../../lib/infrastructure/repositories/user-campaign-results/stage-collection-repository.js')} StageCollectionRepository * @typedef { import ('../../../../evaluation/infrastructure/repositories/badge-repository.js')} BadgeRepository diff --git a/api/src/prescription/target-profile/domain/usecases/index.js b/api/src/prescription/target-profile/domain/usecases/index.js index e5f4befd719..d2f7539f5b5 100644 --- a/api/src/prescription/target-profile/domain/usecases/index.js +++ b/api/src/prescription/target-profile/domain/usecases/index.js @@ -2,7 +2,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import * as learningContentConversionService from '../../../../../lib/domain/services/learning-content/learning-content-conversion-service.js'; -import * as learningContentRepository from '../../../../../lib/infrastructure/repositories/learning-content-repository.js'; +import * as learningContentRepository from '../../../../shared/infrastructure/repositories/learning-content-repository.js'; import { adminMemberRepository } from '../../../../shared/infrastructure/repositories/admin-member.repository.js'; import * as organizationRepository from '../../../../shared/infrastructure/repositories/organization-repository.js'; import { injectDependencies } from '../../../../shared/infrastructure/utils/dependency-injection.js'; @@ -18,7 +18,7 @@ import * as targetProfileSummaryForAdminRepository from '../../infrastructure/re /** * @typedef {import('../../infrastructure/repositories/')} AdminMemberRepository * @typedef {import('../../../../../lib/domain/services/learning-content/learning-content-conversion-service.js')} LearningContentConversionService - * @typedef {import('../../../../../lib/infrastructure/repositories/learning-content-repository.js')} LearningContentRepository + * @typedef {import('../../../../shared/infrastructure/repositories/learning-content-repository.js')} LearningContentRepository * @typedef {import('../../../../shared/infrastructure/repositories/organization-repository.js')} OrganizationRepository * @typedef {import('../../infrastructure/repositories/organizations-to-attach-to-target-profile-repository.js')} OrganizationsToAttachToTargetProfileRepository * @typedef {import('../../infrastructure/repositories/target-profile-administration-repository.js')} TargetProfileAdministrationRepository diff --git a/api/src/school/infrastructure/repositories/mission-repository.js b/api/src/school/infrastructure/repositories/mission-repository.js index cde56ae439a..fb4f57c0d19 100644 --- a/api/src/school/infrastructure/repositories/mission-repository.js +++ b/api/src/school/infrastructure/repositories/mission-repository.js @@ -1,7 +1,7 @@ import { config } from '../../../shared/config.js'; import { LOCALE } from '../../../shared/domain/constants.js'; import { getTranslatedKey } from '../../../shared/domain/services/get-translated-text.js'; -import { LearningContentRepository } from '../../../shared/infrastructure/repositories/learning-content-repository.js'; +import { LearningContentDatasource } from '../../../shared/infrastructure/repositories/learning-content-datasource.js'; import { Mission, MissionContent, MissionStep } from '../../domain/models/Mission.js'; import { MissionNotFoundError } from '../../domain/school-errors.js'; @@ -61,12 +61,12 @@ function toDomain(missionDto, locale) { }); } -/** @type {LearningContentRepository} */ +/** @type {LearningContentDatasource} */ let instance; function getInstance() { if (!instance) { - instance = new LearningContentRepository({ tableName: TABLE_NAME, idType: 'integer' }); + instance = new LearningContentDatasource({ tableName: TABLE_NAME, idType: 'integer' }); } return instance; } diff --git a/api/src/shared/infrastructure/repositories/area-repository.js b/api/src/shared/infrastructure/repositories/area-repository.js index 37aef2f9593..f419ece95e5 100644 --- a/api/src/shared/infrastructure/repositories/area-repository.js +++ b/api/src/shared/infrastructure/repositories/area-repository.js @@ -4,7 +4,7 @@ import { Area } from '../../domain/models/Area.js'; import { getTranslatedKey } from '../../domain/services/get-translated-text.js'; import { child, SCOPES } from '../utils/logger.js'; import * as competenceRepository from './competence-repository.js'; -import { LearningContentRepository } from './learning-content-repository.js'; +import { LearningContentDatasource } from './learning-content-datasource.js'; const TABLE_NAME = 'learningcontent.areas'; @@ -118,12 +118,12 @@ async function toDomainWithCompetences(areaDtos, locale) { return areas; } -/** @type {LearningContentRepository} */ +/** @type {LearningContentDatasource} */ let instance; function getInstance() { if (!instance) { - instance = new LearningContentRepository({ tableName: TABLE_NAME }); + instance = new LearningContentDatasource({ tableName: TABLE_NAME }); } return instance; } diff --git a/api/src/shared/infrastructure/repositories/challenge-repository.js b/api/src/shared/infrastructure/repositories/challenge-repository.js index cad4c91420c..4617dc8dd04 100644 --- a/api/src/shared/infrastructure/repositories/challenge-repository.js +++ b/api/src/shared/infrastructure/repositories/challenge-repository.js @@ -6,7 +6,7 @@ import { Accessibility } from '../../domain/models/Challenge.js'; import { Challenge } from '../../domain/models/index.js'; import * as solutionAdapter from '../../infrastructure/adapters/solution-adapter.js'; import { child, SCOPES } from '../utils/logger.js'; -import { LearningContentRepository } from './learning-content-repository.js'; +import { LearningContentDatasource } from './learning-content-datasource.js'; const logger = child('learningcontent:repository', { event: SCOPES.LEARNING_CONTENT }); @@ -261,12 +261,12 @@ function toDomain({ challengeDto, webComponentTagName, webComponentProps, skill, }); } -/** @type {LearningContentRepository} */ +/** @type {LearningContentDatasource} */ let instance; function getInstance() { if (!instance) { - instance = new LearningContentRepository({ tableName: TABLE_NAME }); + instance = new LearningContentDatasource({ tableName: TABLE_NAME }); } return instance; } diff --git a/api/src/shared/infrastructure/repositories/competence-repository.js b/api/src/shared/infrastructure/repositories/competence-repository.js index 89eb051c74b..0e8d3bb31b7 100644 --- a/api/src/shared/infrastructure/repositories/competence-repository.js +++ b/api/src/shared/infrastructure/repositories/competence-repository.js @@ -3,7 +3,7 @@ import { NotFoundError } from '../../domain/errors.js'; import { Competence } from '../../domain/models/index.js'; import { getTranslatedKey } from '../../domain/services/get-translated-text.js'; import { child, SCOPES } from '../utils/logger.js'; -import { LearningContentRepository } from './learning-content-repository.js'; +import { LearningContentDatasource } from './learning-content-datasource.js'; const { FRENCH_FRANCE } = LOCALE; const TABLE_NAME = 'learningcontent.competences'; @@ -77,12 +77,12 @@ function toDomain({ competenceDto, locale }) { }); } -/** @type {LearningContentRepository} */ +/** @type {LearningContentDatasource} */ let instance; function getInstance() { if (!instance) { - instance = new LearningContentRepository({ tableName: TABLE_NAME }); + instance = new LearningContentDatasource({ tableName: TABLE_NAME }); } return instance; } diff --git a/api/src/shared/infrastructure/repositories/course-repository.js b/api/src/shared/infrastructure/repositories/course-repository.js index 932284ffef8..f40042728e9 100644 --- a/api/src/shared/infrastructure/repositories/course-repository.js +++ b/api/src/shared/infrastructure/repositories/course-repository.js @@ -1,6 +1,6 @@ import { NotFoundError } from '../../domain/errors.js'; import { Course } from '../../domain/models/Course.js'; -import { LearningContentRepository } from './learning-content-repository.js'; +import { LearningContentDatasource } from './learning-content-datasource.js'; const TABLE_NAME = 'learningcontent.courses'; @@ -36,12 +36,12 @@ function toDomain(courseDto) { }); } -/** @type {LearningContentRepository} */ +/** @type {LearningContentDatasource} */ let instance; function getInstance() { if (!instance) { - instance = new LearningContentRepository({ tableName: TABLE_NAME }); + instance = new LearningContentDatasource({ tableName: TABLE_NAME }); } return instance; } diff --git a/api/src/shared/infrastructure/repositories/framework-repository.js b/api/src/shared/infrastructure/repositories/framework-repository.js index 580e1e1587c..715cbde6a1c 100644 --- a/api/src/shared/infrastructure/repositories/framework-repository.js +++ b/api/src/shared/infrastructure/repositories/framework-repository.js @@ -1,7 +1,7 @@ import { NotFoundError } from '../../domain/errors.js'; import { Framework } from '../../domain/models/index.js'; import { child, SCOPES } from '../utils/logger.js'; -import { LearningContentRepository } from './learning-content-repository.js'; +import { LearningContentDatasource } from './learning-content-datasource.js'; const TABLE_NAME = 'learningcontent.frameworks'; @@ -51,12 +51,12 @@ function byName(framework1, framework2) { return collator.compare(framework1.name, framework2.name); } -/** @type {LearningContentRepository} */ +/** @type {LearningContentDatasource} */ let instance; function getInstance() { if (!instance) { - instance = new LearningContentRepository({ tableName: TABLE_NAME }); + instance = new LearningContentDatasource({ tableName: TABLE_NAME }); } return instance; } diff --git a/api/src/shared/infrastructure/repositories/learning-content-datasource.js b/api/src/shared/infrastructure/repositories/learning-content-datasource.js new file mode 100644 index 00000000000..fe347622f4c --- /dev/null +++ b/api/src/shared/infrastructure/repositories/learning-content-datasource.js @@ -0,0 +1,168 @@ +import Dataloader from 'dataloader'; + +import { knex } from '../../../../db/knex-database-connection.js'; +import { LearningContentCache } from '../caches/learning-content-cache.js'; +import { child, SCOPES } from '../utils/logger.js'; + +const logger = child('learningcontent:repository', { event: SCOPES.LEARNING_CONTENT }); + +/** + * @typedef {(knex: import('knex').QueryBuilder) => Promise} QueryBuilderCallback + */ + +/** + * Datasource for learning content repositories. + * This datasource uses a {@link Dataloader} to load and cache entities. + */ +export class LearningContentDatasource { + #tableName; + #idType; + #dataloader; + #findCache; + #findCacheMiss; + + /** + * @param {{ + * tableName: string + * idType?: 'text' | 'integer' + * }} config + */ + constructor({ tableName, idType = 'text' }) { + this.#tableName = tableName; + this.#idType = idType; + + this.#dataloader = new Dataloader((ids) => this.#batchLoad(ids), { + cacheMap: new LearningContentCache({ name: `${tableName}:entities` }), + }); + + this.#findCache = new LearningContentCache({ name: `${tableName}:results` }); + + this.#findCacheMiss = new Map(); + } + + /** + * Finds several entities using a request and caches results. + * The request is built using a knex query builder given to {@link callback}. + * {@link cacheKey} must vary according to params given to the query builder. + * @param {string} cacheKey + * @param {QueryBuilderCallback} callback + * @returns {Promise} + */ + async find(cacheKey, callback) { + let dtos = this.#findCache.get(cacheKey); + if (dtos) return dtos; + + dtos = this.#findCacheMiss.get(cacheKey); + if (dtos) return dtos; + + dtos = this.#loadDtos(callback, cacheKey).finally(() => { + this.#findCacheMiss.delete(cacheKey); + }); + this.#findCacheMiss.set(cacheKey, dtos); + + return dtos; + } + + /** + * Loads one entity by ID. + * @param {string|number} id + * @returns {Promise} + */ + async load(id) { + return this.#dataloader.load(id); + } + + /** + * Gets several entities by ID. + * Deduplicates ids and removes nullish ids before loading. + * @param {string[]|number[]} ids + * @returns {Promise<(object|null)[]>} + */ + async getMany(ids) { + const idsToLoad = new Set(ids); + idsToLoad.delete(undefined); + idsToLoad.delete(null); + + const dtos = await this.loadMany(Array.from(idsToLoad)); + + return dtos; + } + + /** + * Loads several entities by ID. + * @param {string[]|number[]} ids + * @returns {Promise<(object|null)[]>} + */ + async loadMany(ids) { + return this.#loadMany(ids); + } + + /** + * Loads several entities by ID. + * @param {string[]|number[]} ids + * @param {string=} cacheKey for debug purposes only + * @returns {Promise<(object|null)[]>} + */ + async #loadMany(ids, cacheKey) { + const dtos = await this.#dataloader.loadMany(ids); + + if (dtos[0] instanceof Error) { + const err = dtos[0]; + logger.error({ tableName: this.#tableName, cacheKey, err }, 'error while loading entities by ids'); + throw new Error('error while loading entities by ids', { cause: err }); + } + + return dtos; + } + + /** + * Loads entities from database using a request and writes result to cache. + * @param {string} cacheKey + * @param {QueryBuilderCallback} callback + * @returns {Promise} + */ + async #loadDtos(callback, cacheKey) { + const ids = await callback(knex.pluck(`${this.#tableName}.id`).from(this.#tableName)); + + const dtos = await this.#loadMany(ids, cacheKey); + + logger.debug({ tableName: this.#tableName, cacheKey }, 'caching find result'); + this.#findCache.set(cacheKey, dtos); + + return dtos; + } + + /** + * Loads a batch of entities from database by ID. + * Entities are returned in the same order as {@link ids}. + * If an ID is not found, it is null in results. + * @param {string[]|number[]} ids + * @returns {Promise<(object|null)[]>} + */ + async #batchLoad(ids) { + logger.debug({ tableName: this.#tableName, count: ids.length }, 'loading from PG'); + const dtos = await knex + .select(`${this.#tableName}.*`) + .from(knex.raw(`unnest(?::${this.#idType}[]) with ordinality as ids(id, idx)`, [ids])) // eslint-disable-line knex/avoid-injections + .leftJoin(this.#tableName, `${this.#tableName}.id`, 'ids.id') + .orderBy('ids.idx'); + return dtos.map((dto) => (dto.id ? dto : null)); + } + + /** + * Clears repository’s cache. + * If {@link id} is undefined, all cache is cleared. + * If {@link id} is given, cache is partially cleared. + * @param {string|number|undefined} id + */ + clearCache(id) { + logger.debug({ tableName: this.#tableName, id }, 'triggering cache clear'); + + if (id) { + this.#dataloader.clear(id); + } else { + this.#dataloader.clearAll(); + } + this.#findCache.clear(); + } +} diff --git a/api/src/shared/infrastructure/repositories/learning-content-repository.js b/api/src/shared/infrastructure/repositories/learning-content-repository.js index 5d069f3598b..a79bfd1429c 100644 --- a/api/src/shared/infrastructure/repositories/learning-content-repository.js +++ b/api/src/shared/infrastructure/repositories/learning-content-repository.js @@ -1,168 +1,144 @@ -import Dataloader from 'dataloader'; +import _ from 'lodash'; import { knex } from '../../../../db/knex-database-connection.js'; -import { LearningContentCache } from '../caches/learning-content-cache.js'; -import { child, SCOPES } from '../utils/logger.js'; - -const logger = child('learningcontent:repository', { event: SCOPES.LEARNING_CONTENT }); - -/** - * @typedef {(knex: import('knex').QueryBuilder) => Promise} QueryBuilderCallback - */ - -/** - * Datasource for learning content repositories. - * This datasource uses a {@link Dataloader} to load and cache entities. - */ -export class LearningContentRepository { - #tableName; - #idType; - #dataloader; - #findCache; - #findCacheMiss; - - /** - * @param {{ - * tableName: string - * idType?: 'text' | 'integer' - * }} config - */ - constructor({ tableName, idType = 'text' }) { - this.#tableName = tableName; - this.#idType = idType; - - this.#dataloader = new Dataloader((ids) => this.#batchLoad(ids), { - cacheMap: new LearningContentCache({ name: `${tableName}:entities` }), - }); +import * as learningContentConversionService from '../../../../lib/domain/services/learning-content/learning-content-conversion-service.js'; +import * as campaignRepository from '../../../prescription/campaign/infrastructure/repositories/campaign-repository.js'; +import { NoSkillsInCampaignError, NotFoundError } from '../../domain/errors.js'; +import { CampaignLearningContent } from '../../domain/models/CampaignLearningContent.js'; +import { LearningContent } from '../../domain/models/LearningContent.js'; +import * as areaRepository from './area-repository.js'; +import * as competenceRepository from './competence-repository.js'; +import * as frameworkRepository from './framework-repository.js'; +import * as skillRepository from './skill-repository.js'; +import * as thematicRepository from './thematic-repository.js'; +import * as tubeRepository from './tube-repository.js'; + +async function findByCampaignId(campaignId, locale) { + const skills = await campaignRepository.findSkills({ campaignId }); + + const frameworks = await _getLearningContentBySkillIds(skills, locale); + + return new CampaignLearningContent(frameworks); +} - this.#findCache = new LearningContentCache({ name: `${tableName}:results` }); +async function findByTargetProfileId(targetProfileId, locale) { + const cappedTubesDTO = await knex('target-profile_tubes') + .select({ + id: 'tubeId', + level: 'level', + }) + .where({ targetProfileId }); - this.#findCacheMiss = new Map(); + if (cappedTubesDTO.length === 0) { + throw new NotFoundError("Le profil cible n'existe pas"); } - /** - * Finds several entities using a request and caches results. - * The request is built using a knex query builder given to {@link callback}. - * {@link cacheKey} must vary according to params given to the query builder. - * @param {string} cacheKey - * @param {QueryBuilderCallback} callback - * @returns {Promise} - */ - async find(cacheKey, callback) { - let dtos = this.#findCache.get(cacheKey); - if (dtos) return dtos; - - dtos = this.#findCacheMiss.get(cacheKey); - if (dtos) return dtos; - - dtos = this.#loadDtos(callback, cacheKey).finally(() => { - this.#findCacheMiss.delete(cacheKey); - }); - this.#findCacheMiss.set(cacheKey, dtos); + const frameworks = await _getLearningContentByCappedTubes(cappedTubesDTO, locale); + return new LearningContent(frameworks); +} - return dtos; +async function findByFrameworkNames({ frameworkNames, locale }) { + const baseFrameworks = []; + for (const frameworkName of frameworkNames) { + baseFrameworks.push(await frameworkRepository.getByName(frameworkName)); } - /** - * Loads one entity by ID. - * @param {string|number} id - * @returns {Promise} - */ - async load(id) { - return this.#dataloader.load(id); - } + const frameworks = await _getLearningContentByFrameworks(baseFrameworks, locale); + return new LearningContent(frameworks); +} - /** - * Gets several entities by ID. - * Deduplicates ids and removes nullish ids before loading. - * @param {string[]|number[]} ids - * @returns {Promise<(object|null)[]>} - */ - async getMany(ids) { - const idsToLoad = new Set(ids); - idsToLoad.delete(undefined); - idsToLoad.delete(null); +async function _getLearningContentBySkillIds(skills, locale) { + if (_.isEmpty(skills)) { + throw new NoSkillsInCampaignError(); + } + const tubeIds = _.uniq(skills.map((skill) => skill.tubeId)); + const tubes = await tubeRepository.findByRecordIds(tubeIds, locale); - const dtos = await this.loadMany(Array.from(idsToLoad)); + tubes.forEach((tube) => { + tube.skills = skills.filter((skill) => { + return skill.tubeId === tube.id; + }); + }); - return dtos; - } + return _getLearningContentByTubes(tubes, locale); +} - /** - * Loads several entities by ID. - * @param {string[]|number[]} ids - * @returns {Promise<(object|null)[]>} - */ - async loadMany(ids) { - return this.#loadMany(ids); - } +async function _getLearningContentByCappedTubes(cappedTubesDTO, locale) { + const skills = await learningContentConversionService.findActiveSkillsForCappedTubes(cappedTubesDTO); - /** - * Loads several entities by ID. - * @param {string[]|number[]} ids - * @param {string=} cacheKey for debug purposes only - * @returns {Promise<(object|null)[]>} - */ - async #loadMany(ids, cacheKey) { - const dtos = await this.#dataloader.loadMany(ids); - - if (dtos[0] instanceof Error) { - const err = dtos[0]; - logger.error({ tableName: this.#tableName, cacheKey, err }, 'error while loading entities by ids'); - throw new Error('error while loading entities by ids', { cause: err }); - } + const tubes = await tubeRepository.findByRecordIds( + cappedTubesDTO.map((dto) => dto.id), + locale, + ); - return dtos; - } + tubes.forEach((tube) => { + tube.skills = skills.filter((skill) => { + return skill.tubeId === tube.id; + }); + }); - /** - * Loads entities from database using a request and writes result to cache. - * @param {string} cacheKey - * @param {QueryBuilderCallback} callback - * @returns {Promise} - */ - async #loadDtos(callback, cacheKey) { - const ids = await callback(knex.pluck(`${this.#tableName}.id`).from(this.#tableName)); + return _getLearningContentByTubes(tubes, locale); +} - const dtos = await this.#loadMany(ids, cacheKey); +async function _getLearningContentByTubes(tubes, locale) { + const thematicIds = _.uniq(tubes.map((tube) => tube.thematicId)); + const thematics = await thematicRepository.findByRecordIds(thematicIds, locale); + thematics.forEach((thematic) => { + thematic.tubes = tubes.filter((tube) => tube.thematicId === thematic.id); + }); - logger.debug({ tableName: this.#tableName, cacheKey }, 'caching find result'); - this.#findCache.set(cacheKey, dtos); + const competenceIds = _.uniq(tubes.map((tube) => tube.competenceId)); + const competences = await competenceRepository.findByRecordIds({ competenceIds, locale }); - return dtos; + competences.forEach((competence) => { + competence.tubes = tubes.filter((tube) => { + return tube.competenceId === competence.id; + }); + competence.thematics = thematics.filter((thematic) => { + return thematic.competenceId === competence.id; + }); + }); + + const allAreaIds = _.map(competences, (competence) => competence.areaId); + const uniqAreaIds = _.uniq(allAreaIds, 'id'); + const areas = await areaRepository.findByRecordIds({ areaIds: uniqAreaIds, locale }); + for (const area of areas) { + area.competences = competences.filter((competence) => { + return competence.areaId === area.id; + }); } - /** - * Loads a batch of entities from database by ID. - * Entities are returned in the same order as {@link ids}. - * If an ID is not found, it is null in results. - * @param {string[]|number[]} ids - * @returns {Promise<(object|null)[]>} - */ - async #batchLoad(ids) { - logger.debug({ tableName: this.#tableName, count: ids.length }, 'loading from PG'); - const dtos = await knex - .select(`${this.#tableName}.*`) - .from(knex.raw(`unnest(?::${this.#idType}[]) with ordinality as ids(id, idx)`, [ids])) // eslint-disable-line knex/avoid-injections - .leftJoin(this.#tableName, `${this.#tableName}.id`, 'ids.id') - .orderBy('ids.idx'); - return dtos.map((dto) => (dto.id ? dto : null)); + const frameworkIds = _.uniq(areas.map((area) => area.frameworkId)); + const frameworks = await frameworkRepository.findByRecordIds(frameworkIds); + for (const framework of frameworks) { + framework.areas = areas.filter((area) => { + return area.frameworkId === framework.id; + }); } - /** - * Clears repository’s cache. - * If {@link id} is undefined, all cache is cleared. - * If {@link id} is given, cache is partially cleared. - * @param {string|number|undefined} id - */ - clearCache(id) { - logger.debug({ tableName: this.#tableName, id }, 'triggering cache clear'); - - if (id) { - this.#dataloader.clear(id); - } else { - this.#dataloader.clearAll(); + return frameworks; +} + +async function _getLearningContentByFrameworks(frameworks, locale) { + for (const framework of frameworks) { + framework.areas = await areaRepository.findByFrameworkId({ frameworkId: framework.id, locale }); + for (const area of framework.areas) { + area.competences = await competenceRepository.findByAreaId({ areaId: area.id, locale }); + for (const competence of area.competences) { + competence.thematics = await thematicRepository.findByCompetenceIds([competence.id], locale); + for (const thematic of competence.thematics) { + const tubes = await tubeRepository.findActiveByRecordIds(thematic.tubeIds, locale); + thematic.tubes = tubes; + competence.tubes.push(...tubes); + for (const tube of thematic.tubes) { + tube.skills = await skillRepository.findActiveByTubeId(tube.id); + } + } + } } - this.#findCache.clear(); } + + return frameworks; } + +export { findByCampaignId, findByFrameworkNames, findByTargetProfileId }; diff --git a/api/src/shared/infrastructure/repositories/skill-repository.js b/api/src/shared/infrastructure/repositories/skill-repository.js index f851dd379ba..ce2be212de1 100644 --- a/api/src/shared/infrastructure/repositories/skill-repository.js +++ b/api/src/shared/infrastructure/repositories/skill-repository.js @@ -3,7 +3,7 @@ import { NotFoundError } from '../../domain/errors.js'; import { Skill } from '../../domain/models/Skill.js'; import { getTranslatedKey } from '../../domain/services/get-translated-text.js'; import { child, SCOPES } from '../utils/logger.js'; -import { LearningContentRepository } from './learning-content-repository.js'; +import { LearningContentDatasource } from './learning-content-datasource.js'; const TABLE_NAME = 'learningcontent.skills'; const ACTIVE_STATUS = 'actif'; @@ -119,12 +119,12 @@ function toDomain(skillDto, locale, useFallback) { }); } -/** @type {LearningContentRepository} */ +/** @type {LearningContentDatasource} */ let instance; function getInstance() { if (!instance) { - instance = new LearningContentRepository({ tableName: TABLE_NAME }); + instance = new LearningContentDatasource({ tableName: TABLE_NAME }); } return instance; } diff --git a/api/src/shared/infrastructure/repositories/thematic-repository.js b/api/src/shared/infrastructure/repositories/thematic-repository.js index 812e5c5a44d..13370218eda 100644 --- a/api/src/shared/infrastructure/repositories/thematic-repository.js +++ b/api/src/shared/infrastructure/repositories/thematic-repository.js @@ -1,7 +1,7 @@ import { LOCALE } from '../../domain/constants.js'; import { Thematic } from '../../domain/models/Thematic.js'; import { getTranslatedKey } from '../../domain/services/get-translated-text.js'; -import { LearningContentRepository } from './learning-content-repository.js'; +import { LearningContentDatasource } from './learning-content-datasource.js'; const { FRENCH_FRANCE } = LOCALE; const TABLE_NAME = 'learningcontent.thematics'; @@ -48,12 +48,12 @@ function toDomain(thematicDto, locale) { }); } -/** @type {LearningContentRepository} */ +/** @type {LearningContentDatasource} */ let instance; function getInstance() { if (!instance) { - instance = new LearningContentRepository({ tableName: TABLE_NAME }); + instance = new LearningContentDatasource({ tableName: TABLE_NAME }); } return instance; } diff --git a/api/src/shared/infrastructure/repositories/tube-repository.js b/api/src/shared/infrastructure/repositories/tube-repository.js index 130b8dcfdce..da1e098ebda 100644 --- a/api/src/shared/infrastructure/repositories/tube-repository.js +++ b/api/src/shared/infrastructure/repositories/tube-repository.js @@ -3,7 +3,7 @@ import { LearningContentResourceNotFound } from '../../domain/errors.js'; import { Tube } from '../../domain/models/Tube.js'; import { getTranslatedKey } from '../../domain/services/get-translated-text.js'; import { child, SCOPES } from '../utils/logger.js'; -import { LearningContentRepository } from './learning-content-repository.js'; +import { LearningContentDatasource } from './learning-content-datasource.js'; const TABLE_NAME = 'learningcontent.tubes'; const ACTIVE_STATUS = 'actif'; @@ -85,12 +85,12 @@ function toDomain(tubeDto, locale) { }); } -/** @type {LearningContentRepository} */ +/** @type {LearningContentDatasource} */ let instance; function getInstance() { if (!instance) { - instance = new LearningContentRepository({ tableName: TABLE_NAME }); + instance = new LearningContentDatasource({ tableName: TABLE_NAME }); } return instance; } diff --git a/api/tests/integration/infrastructure/repositories/learning-content-repository_test.js b/api/tests/integration/infrastructure/repositories/learning-content-repository_test.js deleted file mode 100644 index 068a8f15bf4..00000000000 --- a/api/tests/integration/infrastructure/repositories/learning-content-repository_test.js +++ /dev/null @@ -1,486 +0,0 @@ -import * as learningContentRepository from '../../../../lib/infrastructure/repositories/learning-content-repository.js'; -import { NoSkillsInCampaignError, NotFoundError } from '../../../../src/shared/domain/errors.js'; -import { catchErr, databaseBuilder, domainBuilder, expect } from '../../../test-helper.js'; - -describe('Integration | Repository | learning-content', function () { - let framework1Fr, framework1En, framework2Fr, framework2En; - let area1Fr, area1En, area2Fr, area2En; - let competence1Fr, competence1En, competence2Fr, competence2En, competence3Fr, competence3En; - let thematic1Fr, thematic1En, thematic2Fr, thematic2En, thematic3Fr, thematic3En; - let tube1Fr, tube1En, tube2Fr, tube2En, tube4Fr, tube4En; - let skill1Fr, skill2Fr, skill3Fr, skill8Fr; - - beforeEach(async function () { - const framework1DB = databaseBuilder.factory.learningContent.buildFramework({ - id: 'recFramework1', - name: 'Mon référentiel 1', - }); - const framework2DB = databaseBuilder.factory.learningContent.buildFramework({ - id: 'recFramework2', - name: 'Mon référentiel 2', - }); - const area1DB = databaseBuilder.factory.learningContent.buildArea({ - id: 'recArea1', - name: 'area1_name', - title_i18n: { fr: 'domaine1_TitreFr', en: 'area1_TitleEn' }, - color: 'area1_color', - code: 'area1_code', - frameworkId: 'recFramework1', - competenceIds: ['recCompetence1', 'recCompetence2'], - }); - const area2DB = databaseBuilder.factory.learningContent.buildArea({ - id: 'recArea2', - name: 'area2_name', - title_i18n: { fr: 'domaine2_TitreFr', en: 'area2_TitleEn' }, - color: 'area2_color', - code: 'area2_code', - frameworkId: 'recFramework2', - competenceIds: ['recCompetence3'], - }); - const competence1DB = databaseBuilder.factory.learningContent.buildCompetence({ - id: 'recCompetence1', - name_i18n: { fr: 'competence1_nomFr', en: 'competence1_nameEn' }, - index: '1', - description_i18n: { fr: 'competence1_descriptionFr', en: 'competence1_descriptionEn' }, - origin: 'Pix', - areaId: 'recArea1', - }); - const competence2DB = databaseBuilder.factory.learningContent.buildCompetence({ - id: 'recCompetence2', - name_i18n: { fr: 'competence2_nomFr', en: 'competence2_nameEn' }, - index: '2', - description_i18n: { fr: 'competence2_descriptionFr', en: 'competence2_descriptionEn' }, - origin: 'Pix', - areaId: 'recArea1', - }); - const competence3DB = databaseBuilder.factory.learningContent.buildCompetence({ - id: 'recCompetence3', - name_i18n: { fr: 'competence3_nomFr', en: 'competence3_nameEn' }, - index: '1', - description_i18n: { fr: 'competence3_descriptionFr', en: 'competence3_descriptionEn' }, - origin: 'Pix', - areaId: 'recArea2', - }); - const thematic1DB = databaseBuilder.factory.learningContent.buildThematic({ - id: 'recThematic1', - name_i18n: { - fr: 'thematique1_nomFr', - en: 'thematic1_nameEn', - }, - index: 10, - competenceId: 'recCompetence1', - tubeIds: ['recTube1'], - }); - const thematic2DB = databaseBuilder.factory.learningContent.buildThematic({ - id: 'recThematic2', - name_i18n: { - fr: 'thematique2_nomFr', - en: 'thematic2_nameEn', - }, - index: 20, - competenceId: 'recCompetence2', - tubeIds: ['recTube2', 'recTube3'], - }); - const thematic3DB = databaseBuilder.factory.learningContent.buildThematic({ - id: 'recThematic3', - name_i18n: { - fr: 'thematique3_nomFr', - en: 'thematic3_nameEn', - }, - index: 30, - competenceId: 'recCompetence3', - tubeIds: ['recTube4'], - }); - const tube1DB = databaseBuilder.factory.learningContent.buildTube({ - id: 'recTube1', - name: '@tube1_name', - title: 'tube1_title', - description: 'tube1_description', - practicalTitle_i18n: { fr: 'tube1_practicalTitleFr', en: 'tube1_practicalTitleEn' }, - practicalDescription_i18n: { - fr: 'tube1_practicalDescriptionFr', - en: 'tube1_practicalDescriptionEn', - }, - isMobileCompliant: true, - isTabletCompliant: false, - competenceId: 'recCompetence1', - thematicId: 'recThematic1', - }); - const tube2DB = databaseBuilder.factory.learningContent.buildTube({ - id: 'recTube2', - name: '@tube2_name', - title: '@tube2_title', - description: '@tube2_description', - practicalTitle_i18n: { fr: 'tube2_practicalTitleFr', en: 'tube2_practicalTitleEn' }, - practicalDescription_i18n: { - fr: 'tube2_practicalDescriptionFr', - en: 'tube2_practicalDescriptionEn', - }, - isMobileCompliant: false, - isTabletCompliant: true, - competenceId: 'recCompetence2', - thematicId: 'recThematic2', - }); - const tube3DB = databaseBuilder.factory.learningContent.buildTube({ - id: 'recTube3', - name: '@tube3_name', - title: '@tube3_title', - description: '@tube3_description', - practicalTitle_i18n: { fr: 'tube3_practicalTitleFr', en: 'tube3_practicalTitleEn' }, - practicalDescription_i18n: { - fr: 'tube3_practicalDescriptionFr', - en: 'tube3_practicalDescriptionEn', - }, - isMobileCompliant: true, - isTabletCompliant: true, - competenceId: 'recCompetence2', - thematicId: 'recThematic2', - }); - const tube4DB = databaseBuilder.factory.learningContent.buildTube({ - id: 'recTube4', - name: '@tube4_name', - title: 'tube4_title', - description: 'tube4_description', - practicalTitle_i18n: { fr: 'tube4_practicalTitleFr', en: 'tube4_practicalTitleEn' }, - practicalDescription_i18n: { - fr: 'tube4_practicalDescriptionFr', - en: 'tube4_practicalDescriptionEn', - }, - isMobileCompliant: false, - isTabletCompliant: false, - competenceId: 'recCompetence3', - thematicId: 'recThematic3', - }); - const skill1DB = databaseBuilder.factory.learningContent.buildSkill({ - id: 'recSkill1', - name: '@tube1_name4', - status: 'actif', - level: 4, - pixValue: 12, - version: 98, - tubeId: 'recTube1', - }); - const skill2DB = databaseBuilder.factory.learningContent.buildSkill({ - id: 'recSkill2', - name: '@tube2_name1', - status: 'actif', - level: 1, - pixValue: 34, - version: 76, - tubeId: 'recTube2', - }); - const skill3DB = databaseBuilder.factory.learningContent.buildSkill({ - id: 'recSkill3', - name: '@tube2_name2', - status: 'archivé', - level: 2, - pixValue: 56, - version: 54, - tubeId: 'recTube2', - }); - const skill4DB = databaseBuilder.factory.learningContent.buildSkill({ - id: 'recSkill4', - status: 'périmé', - tubeId: 'recTube2', - }); - const skill5DB = databaseBuilder.factory.learningContent.buildSkill({ - id: 'recSkill5', - name: '@tube3_name5', - status: 'archivé', - level: 5, - pixValue: 44, - version: 55, - tubeId: 'recTube3', - }); - const skill6DB = databaseBuilder.factory.learningContent.buildSkill({ - id: 'recSkill6', - status: 'périmé', - tubeId: 'recTube3', - }); - const skill7DB = databaseBuilder.factory.learningContent.buildSkill({ - id: 'recSkill7', - status: 'périmé', - tubeId: 'recTube3', - }); - const skill8DB = databaseBuilder.factory.learningContent.buildSkill({ - id: 'recSkill8', - name: '@tube4_name8', - status: 'actif', - level: 7, - pixValue: 78, - version: 32, - tubeId: 'recTube4', - }); - await databaseBuilder.commit(); - - [framework1Fr, framework2Fr] = _buildDomainFrameworksFromDB([framework1DB, framework2DB]); - [framework1En, framework2En] = _buildDomainFrameworksFromDB([framework1DB, framework2DB]); - [area1Fr, area2Fr] = _buildDomainAreasFromDB([area1DB, area2DB], 'fr'); - [area1En, area2En] = _buildDomainAreasFromDB([area1DB, area2DB], 'en'); - [competence1Fr, competence2Fr, competence3Fr] = _buildDomainCompetencesFromDB( - [competence1DB, competence2DB, competence3DB], - 'fr', - ); - [competence1En, competence2En, competence3En] = _buildDomainCompetencesFromDB( - [competence1DB, competence2DB, competence3DB], - 'en', - ); - [thematic1Fr, thematic2Fr, thematic3Fr] = _buildDomainThematicsFromDB( - [thematic1DB, thematic2DB, thematic3DB], - 'fr', - ); - [thematic1En, thematic2En, thematic3En] = _buildDomainThematicsFromDB( - [thematic1DB, thematic2DB, thematic3DB], - 'en', - ); - [tube1Fr, tube2Fr, , tube4Fr] = _buildDomainTubesFromDB([tube1DB, tube2DB, tube3DB, tube4DB], 'fr'); - [tube1En, tube2En, , tube4En] = _buildDomainTubesFromDB([tube1DB, tube2DB, tube3DB, tube4DB], 'en'); - [skill1Fr, skill2Fr, skill3Fr, , , , , skill8Fr] = _buildDomainSkillsFromDB( - [skill1DB, skill2DB, skill3DB, skill4DB, skill5DB, skill6DB, skill7DB, skill8DB], - 'fr', - ); - }); - - describe('#findByCampaignId', function () { - let campaignId; - - it('should return frameworks, areas, competences, thematics and tubes of the skills hierarchy', async function () { - // given - campaignId = databaseBuilder.factory.buildCampaign().id; - ['recSkill2', 'recSkill3'].forEach((skillId) => - databaseBuilder.factory.buildCampaignSkill({ campaignId, skillId }), - ); - await databaseBuilder.commit(); - - framework1Fr.areas = [area1Fr]; - area1Fr.competences = [competence2Fr]; - competence2Fr.thematics = [thematic2Fr]; - competence2Fr.tubes = [tube2Fr]; - thematic2Fr.tubes = [tube2Fr]; - tube2Fr.skills = [skill2Fr, skill3Fr]; - - // when - const learningContentFromCampaign = await learningContentRepository.findByCampaignId(campaignId); - - // then - expect(learningContentFromCampaign.areas).to.deep.equal([area1Fr]); - expect(learningContentFromCampaign.frameworks).to.deep.equal([framework1Fr]); - }); - - it('should translate names and descriptions when specifying a locale', async function () { - // given - campaignId = databaseBuilder.factory.buildCampaign().id; - ['recSkill2', 'recSkill3'].forEach((skillId) => - databaseBuilder.factory.buildCampaignSkill({ campaignId, skillId }), - ); - await databaseBuilder.commit(); - - framework1En.areas = [area1En]; - area1En.competences = [competence2En]; - competence2En.thematics = [thematic2En]; - competence2En.tubes = [tube2En]; - thematic2En.tubes = [tube2En]; - tube2En.skills = [skill2Fr, skill3Fr]; - - // when - const learningContentFromCampaign = await learningContentRepository.findByCampaignId(campaignId, 'en'); - - // then - expect(learningContentFromCampaign.frameworks).to.deep.equal([framework1En]); - }); - - it('should throw a NoSkillsInCampaignError when there are no more operative skills', async function () { - // given - campaignId = databaseBuilder.factory.buildCampaign().id; - databaseBuilder.factory.buildCampaignSkill({ campaignId, skillId: 'recSkill4' }); - await databaseBuilder.commit(); - - // when - const err = await catchErr(learningContentRepository.findByCampaignId)(campaignId); - - // then - expect(err).to.be.instanceOf(NoSkillsInCampaignError); - }); - }); - - describe('#findByTargetProfileId', function () { - context('when target profile does not have capped tubes', function () { - it('should throw a NotFound error', async function () { - // given - const targetProfileId = databaseBuilder.factory.buildTargetProfile().id; - const anotherTargetProfileId = databaseBuilder.factory.buildTargetProfile().id; - databaseBuilder.factory.buildTargetProfileTube({ targetProfileId: anotherTargetProfileId }); - await databaseBuilder.commit(); - - // when - const error = await catchErr(learningContentRepository.findByTargetProfileId)(targetProfileId); - - // then - expect(error).to.be.instanceOf(NotFoundError); - expect(error.message).to.equal("Le profil cible n'existe pas"); - }); - }); - - context('when target profile has capped tubes', function () { - it('should return frameworks, areas, competences, thematics and tubes of the active skills hierarchy with default FR language', async function () { - // given - const targetProfileId = databaseBuilder.factory.buildTargetProfile().id; - databaseBuilder.factory.buildTargetProfileTube({ targetProfileId, tubeId: 'recTube2', level: 2 }); - await databaseBuilder.commit(); - - framework1Fr.areas = [area1Fr]; - area1Fr.competences = [competence2Fr]; - competence2Fr.thematics = [thematic2Fr]; - competence2Fr.tubes = [tube2Fr]; - thematic2Fr.tubes = [tube2Fr]; - tube2Fr.skills = [skill2Fr]; - - // when - const targetProfileLearningContent = await learningContentRepository.findByTargetProfileId(targetProfileId); - - // then - expect(targetProfileLearningContent.frameworks).to.deep.equal([framework1Fr]); - }); - - context('when using a specific locale', function () { - it('should translate names and descriptions', async function () { - // given - const targetProfileId = databaseBuilder.factory.buildTargetProfile().id; - databaseBuilder.factory.buildTargetProfileTube({ targetProfileId, tubeId: 'recTube2', level: 2 }); - await databaseBuilder.commit(); - - framework1En.areas = [area1En]; - area1En.competences = [competence2En]; - competence2En.thematics = [thematic2En]; - competence2En.tubes = [tube2En]; - thematic2En.tubes = [tube2En]; - tube2En.skills = [skill2Fr]; - - // when - const targetProfileLearningContent = await learningContentRepository.findByTargetProfileId( - targetProfileId, - 'en', - ); - - // then - expect(targetProfileLearningContent.frameworks).to.deep.equal([framework1En]); - }); - }); - }); - }); - - describe('#findByFrameworkNames', function () { - it('should return an active LearningContent with the frameworks designated by name', async function () { - // given - framework1Fr.areas = [area1Fr]; - framework2Fr.areas = [area2Fr]; - area1Fr.competences = [competence1Fr, competence2Fr]; - area2Fr.competences = [competence3Fr]; - competence1Fr.thematics = [thematic1Fr]; - competence1Fr.tubes = [tube1Fr]; - competence2Fr.thematics = [thematic2Fr]; - competence2Fr.tubes = [tube2Fr]; - competence3Fr.thematics = [thematic3Fr]; - competence3Fr.tubes = [tube4Fr]; - thematic1Fr.tubes = [tube1Fr]; - thematic2Fr.tubes = [tube2Fr]; - thematic3Fr.tubes = [tube4Fr]; - tube1Fr.skills = [skill1Fr]; - tube2Fr.skills = [skill2Fr]; - tube4Fr.skills = [skill8Fr]; - - // when - const learningContent = await learningContentRepository.findByFrameworkNames({ - frameworkNames: ['Mon référentiel 1', 'Mon référentiel 2'], - }); - - // then - const expectedLearningContent = domainBuilder.buildLearningContent([framework1Fr, framework2Fr]); - expect(learningContent).to.deepEqualInstance(expectedLearningContent); - }); - - it('should return an active LearningContent in the given language', async function () { - // given - framework1En.areas = [area1En]; - framework2En.areas = [area2En]; - area1En.competences = [competence1En, competence2En]; - area2En.competences = [competence3En]; - competence1En.thematics = [thematic1En]; - competence1En.tubes = [tube1En]; - competence2En.thematics = [thematic2En]; - competence2En.tubes = [tube2En]; - competence3En.thematics = [thematic3En]; - competence3En.tubes = [tube4En]; - thematic1En.tubes = [tube1En]; - thematic2En.tubes = [tube2En]; - thematic3En.tubes = [tube4En]; - tube1En.skills = [skill1Fr]; - tube2En.skills = [skill2Fr]; - tube4En.skills = [skill8Fr]; - - // when - const learningContent = await learningContentRepository.findByFrameworkNames({ - frameworkNames: ['Mon référentiel 1', 'Mon référentiel 2'], - locale: 'en', - }); - - // then - const expectedLearningContent = domainBuilder.buildLearningContent([framework1En, framework2En]); - expect(learningContent).to.deepEqualInstance(expectedLearningContent); - }); - }); -}); - -function _buildDomainFrameworksFromDB(frameworksDB) { - return frameworksDB.map((frameworkDB) => - domainBuilder.buildFramework({ - id: frameworkDB.id, - name: frameworkDB.name, - areas: [], - }), - ); -} - -function _buildDomainAreasFromDB(areasDB, locale) { - return areasDB.map((areaDB) => - domainBuilder.buildArea({ - ...areaDB, - title: areaDB.title_i18n[locale], - }), - ); -} - -function _buildDomainCompetencesFromDB(competencesDB, locale) { - return competencesDB.map((competenceDB) => - domainBuilder.buildCompetence({ - ...competenceDB, - name: competenceDB.name_i18n[locale], - description: competenceDB.description_i18n[locale], - }), - ); -} - -function _buildDomainThematicsFromDB(thematicsDB, locale) { - return thematicsDB.map((thematicDB) => - domainBuilder.buildThematic({ - ...thematicDB, - name: thematicDB.name_i18n[locale], - }), - ); -} - -function _buildDomainTubesFromDB(tubesDB, locale) { - return tubesDB.map((tubeDB) => - domainBuilder.buildTube({ - ...tubeDB, - practicalTitle: tubeDB.practicalTitle_i18n[locale], - practicalDescription: tubeDB.practicalDescription_i18n[locale], - }), - ); -} - -function _buildDomainSkillsFromDB(skillsDB, locale) { - return skillsDB.map((skillDB) => - domainBuilder.buildSkill({ ...skillDB, difficulty: skillDB.level, hint: skillDB.hint_i18n[locale] }), - ); -} diff --git a/api/tests/shared/integration/infrastructure/repositories/learning-content-datasource_test.js b/api/tests/shared/integration/infrastructure/repositories/learning-content-datasource_test.js new file mode 100644 index 00000000000..c3cd3f1d9fc --- /dev/null +++ b/api/tests/shared/integration/infrastructure/repositories/learning-content-datasource_test.js @@ -0,0 +1,268 @@ +import { LearningContentDatasource } from '../../../../../src/shared/infrastructure/repositories/learning-content-datasource.js'; +import { catchErr, expect, knex, sinon } from '../../../../test-helper.js'; + +const SCHEMA_NAME = 'learningcontent'; +const TABLE_NAME = 'entities'; + +describe('Integration | Repository | learning-datasource', function () { + /** @type {string} */ + let tableName; + + /** @type {LearningContentDatasource} */ + let repository; + + /** @type {sinon.SinonStub} */ + let queryHook; + + before(function () { + tableName = `${SCHEMA_NAME}.${TABLE_NAME}`; + repository = new LearningContentDatasource({ tableName }); + }); + + beforeEach(async function () { + repository.clearCache(); + + if (await knex.schema.withSchema(SCHEMA_NAME).hasTable(TABLE_NAME)) { + await knex.schema.withSchema(SCHEMA_NAME).dropTable(TABLE_NAME); + } + + await knex.schema.withSchema(SCHEMA_NAME).createTable(TABLE_NAME, (t) => { + t.string('id').primary(); + t.string('name'); + t.string('group'); + }); + + await knex.withSchema(SCHEMA_NAME).insert({ id: 'entity1', name: 'Entity 1', group: 'group1' }).into(TABLE_NAME); + await knex.withSchema(SCHEMA_NAME).insert({ id: 'entity2', name: 'Entity 2', group: 'group1' }).into(TABLE_NAME); + await knex.withSchema(SCHEMA_NAME).insert({ id: 'entity3', name: 'Entity 3', group: 'group1' }).into(TABLE_NAME); + await knex.withSchema(SCHEMA_NAME).insert({ id: 'entity4', name: 'Entity 4', group: 'group2' }).into(TABLE_NAME); + await knex.withSchema(SCHEMA_NAME).insert({ id: 'entity5', name: 'Entity 5', group: 'group2' }).into(TABLE_NAME); + + queryHook = sinon.stub(); + knex.addListener('query', queryHook); + }); + + afterEach(async function () { + knex.removeListener('query', queryHook); + }); + + describe('#find', function () { + describe('when no database errors', function () { + it('should return matched entities', async function () { + // given + const group = 'group1'; + const cacheKey = 'findByGroup(group1)'; + const callback = (knex) => knex.where({ group }).orderBy('id'); + + // when + const dtos = await repository.find(cacheKey, callback); + + // then + expect(dtos).to.deep.equal([ + { id: 'entity1', name: 'Entity 1', group: 'group1' }, + { id: 'entity2', name: 'Entity 2', group: 'group1' }, + { id: 'entity3', name: 'Entity 3', group: 'group1' }, + ]); + expect(queryHook).to.have.been.calledTwice; + }); + + describe('when result is cached', function () { + it('should return entities from cache', async function () { + // given + const group = 'group1'; + const cacheKey = 'findByGroup(group1)'; + const callback = (knex) => knex.where({ group }).orderBy('id'); + await repository.find(cacheKey, callback); + queryHook.reset(); + + // when + const dtos = await repository.find(cacheKey, callback); + + // then + expect(dtos).to.deep.equal([ + { id: 'entity1', name: 'Entity 1', group: 'group1' }, + { id: 'entity2', name: 'Entity 2', group: 'group1' }, + { id: 'entity3', name: 'Entity 3', group: 'group1' }, + ]); + expect(queryHook).not.to.have.been.called; + }); + }); + }); + + describe('when database error in find ids query', function () { + it('should throw an Error', async function () { + // given + const group = 'group1'; + const cacheKey = 'findByGroup(group1)'; + const callback = (knex) => knex.where({ group }).orderBy('id'); + queryHook.onFirstCall().throws(new Error()); + + // when + const err = await catchErr((...args) => repository.find(...args))(cacheKey, callback); + + // then + expect(err).to.be.instanceOf(Error); + expect(queryHook).to.have.been.calledOnce; + }); + }); + + describe('when database error in load entities query', function () { + it('should throw an Error', async function () { + // given + const group = 'group1'; + const cacheKey = 'findByGroup(group1)'; + const callback = (knex) => knex.where({ group }).orderBy('id'); + queryHook.onSecondCall().throws(new Error()); + + // when + const err = await catchErr((...args) => repository.find(...args))(cacheKey, callback); + + // then + expect(err).to.be.instanceOf(Error); + expect(queryHook).to.have.been.calledTwice; + }); + }); + }); + + describe('#loadMany', function () { + describe('when no database errors', function () { + it('should return entities', async function () { + // given + const ids = ['entity4', 'entity1', 'entity5']; + + // when + const dtos = await repository.loadMany(ids); + + // then + expect(dtos).to.deep.equal([ + { id: 'entity4', name: 'Entity 4', group: 'group2' }, + { id: 'entity1', name: 'Entity 1', group: 'group1' }, + { id: 'entity5', name: 'Entity 5', group: 'group2' }, + ]); + expect(queryHook).to.have.been.calledOnce; + }); + + describe('when result is cached', function () { + it('should return entities from cache', async function () { + // given + const ids = ['entity4', 'entity1', 'entity5']; + await repository.loadMany(ids); + queryHook.reset(); + + // when + const dtos = await repository.loadMany(ids); + + // then + expect(dtos).to.deep.equal([ + { id: 'entity4', name: 'Entity 4', group: 'group2' }, + { id: 'entity1', name: 'Entity 1', group: 'group1' }, + { id: 'entity5', name: 'Entity 5', group: 'group2' }, + ]); + expect(queryHook).not.to.have.been.called; + }); + }); + }); + + describe('when database error', function () { + it('should throw an Error', async function () { + // given + const ids = ['entity4', 'entity1', 'entity5']; + queryHook.onFirstCall().throws(new Error()); + + // when + const err = await catchErr((...args) => repository.loadMany(...args))(ids); + + // then + expect(err).to.be.instanceOf(Error); + expect(queryHook).to.have.been.calledOnce; + }); + }); + }); + + describe('#load', function () { + describe('when no database errors', function () { + it('should return entity', async function () { + // given + const id = 'entity3'; + + // when + const dto = await repository.load(id); + + // then + expect(dto).to.deep.equal({ id: 'entity3', name: 'Entity 3', group: 'group1' }); + expect(queryHook).to.have.been.calledOnce; + }); + + describe('when result is cached', function () { + it('should return entity from cache', async function () { + // given + const id = 'entity3'; + await repository.load(id); + queryHook.reset(); + + // when + const dto = await repository.load(id); + + // then + expect(dto).to.deep.equal({ id: 'entity3', name: 'Entity 3', group: 'group1' }); + expect(queryHook).not.to.have.been.called; + }); + }); + }); + + describe('when database error', function () { + it('should throw an Error', async function () { + // given + const id = 'entity3'; + queryHook.onFirstCall().throws(new Error()); + + // when + const err = await catchErr((...args) => repository.load(...args))(id); + + // then + expect(err).to.be.instanceOf(Error); + expect(queryHook).to.have.been.calledOnce; + }); + }); + }); + + describe('getMany', function () { + describe('when no database errors', function () { + it('should return entities', async function () { + // given + const ids = ['entity4', null, 'entity1', 'entity4', undefined, 'entity5', 'entity5']; + + // when + const dtos = await repository.getMany(ids); + + // then + expect(dtos).to.deep.equal([ + { id: 'entity4', name: 'Entity 4', group: 'group2' }, + { id: 'entity1', name: 'Entity 1', group: 'group1' }, + { id: 'entity5', name: 'Entity 5', group: 'group2' }, + ]); + expect(queryHook).to.have.been.calledOnce; + }); + + describe('when result is cached', function () { + it('should return entities from cache', async function () { + // given + const ids = ['entity4', null, 'entity1', 'entity4', undefined, 'entity5', 'entity5']; + await repository.getMany(ids); + queryHook.reset(); + + // when + const dtos = await repository.getMany(ids); + + // then + expect(dtos).to.deep.equal([ + { id: 'entity4', name: 'Entity 4', group: 'group2' }, + { id: 'entity1', name: 'Entity 1', group: 'group1' }, + { id: 'entity5', name: 'Entity 5', group: 'group2' }, + ]); + expect(queryHook).not.to.have.been.called; + }); + }); + }); + }); +}); diff --git a/api/tests/shared/integration/infrastructure/repositories/learning-content-repository_test.js b/api/tests/shared/integration/infrastructure/repositories/learning-content-repository_test.js index c250474398e..083610668f8 100644 --- a/api/tests/shared/integration/infrastructure/repositories/learning-content-repository_test.js +++ b/api/tests/shared/integration/infrastructure/repositories/learning-content-repository_test.js @@ -1,268 +1,486 @@ -import { LearningContentRepository } from '../../../../../src/shared/infrastructure/repositories/learning-content-repository.js'; -import { catchErr, expect, knex, sinon } from '../../../../test-helper.js'; - -const SCHEMA_NAME = 'learningcontent'; -const TABLE_NAME = 'entities'; - -describe('Integration | Repository | learning-repository', function () { - /** @type {string} */ - let tableName; - - /** @type {LearningContentRepository} */ - let repository; - - /** @type {sinon.SinonStub} */ - let queryHook; - - before(function () { - tableName = `${SCHEMA_NAME}.${TABLE_NAME}`; - repository = new LearningContentRepository({ tableName }); - }); +import { NoSkillsInCampaignError, NotFoundError } from '../../../../../src/shared/domain/errors.js'; +import * as learningContentRepository from '../../../../../src/shared/infrastructure/repositories/learning-content-repository.js'; +import { catchErr, databaseBuilder, domainBuilder, expect } from '../../../../test-helper.js'; + +describe('Integration | Repository | learning-content', function () { + let framework1Fr, framework1En, framework2Fr, framework2En; + let area1Fr, area1En, area2Fr, area2En; + let competence1Fr, competence1En, competence2Fr, competence2En, competence3Fr, competence3En; + let thematic1Fr, thematic1En, thematic2Fr, thematic2En, thematic3Fr, thematic3En; + let tube1Fr, tube1En, tube2Fr, tube2En, tube4Fr, tube4En; + let skill1Fr, skill2Fr, skill3Fr, skill8Fr; beforeEach(async function () { - repository.clearCache(); - - if (await knex.schema.withSchema(SCHEMA_NAME).hasTable(TABLE_NAME)) { - await knex.schema.withSchema(SCHEMA_NAME).dropTable(TABLE_NAME); - } - - await knex.schema.withSchema(SCHEMA_NAME).createTable(TABLE_NAME, (t) => { - t.string('id').primary(); - t.string('name'); - t.string('group'); + const framework1DB = databaseBuilder.factory.learningContent.buildFramework({ + id: 'recFramework1', + name: 'Mon référentiel 1', }); - - await knex.withSchema(SCHEMA_NAME).insert({ id: 'entity1', name: 'Entity 1', group: 'group1' }).into(TABLE_NAME); - await knex.withSchema(SCHEMA_NAME).insert({ id: 'entity2', name: 'Entity 2', group: 'group1' }).into(TABLE_NAME); - await knex.withSchema(SCHEMA_NAME).insert({ id: 'entity3', name: 'Entity 3', group: 'group1' }).into(TABLE_NAME); - await knex.withSchema(SCHEMA_NAME).insert({ id: 'entity4', name: 'Entity 4', group: 'group2' }).into(TABLE_NAME); - await knex.withSchema(SCHEMA_NAME).insert({ id: 'entity5', name: 'Entity 5', group: 'group2' }).into(TABLE_NAME); - - queryHook = sinon.stub(); - knex.addListener('query', queryHook); - }); - - afterEach(async function () { - knex.removeListener('query', queryHook); + const framework2DB = databaseBuilder.factory.learningContent.buildFramework({ + id: 'recFramework2', + name: 'Mon référentiel 2', + }); + const area1DB = databaseBuilder.factory.learningContent.buildArea({ + id: 'recArea1', + name: 'area1_name', + title_i18n: { fr: 'domaine1_TitreFr', en: 'area1_TitleEn' }, + color: 'area1_color', + code: 'area1_code', + frameworkId: 'recFramework1', + competenceIds: ['recCompetence1', 'recCompetence2'], + }); + const area2DB = databaseBuilder.factory.learningContent.buildArea({ + id: 'recArea2', + name: 'area2_name', + title_i18n: { fr: 'domaine2_TitreFr', en: 'area2_TitleEn' }, + color: 'area2_color', + code: 'area2_code', + frameworkId: 'recFramework2', + competenceIds: ['recCompetence3'], + }); + const competence1DB = databaseBuilder.factory.learningContent.buildCompetence({ + id: 'recCompetence1', + name_i18n: { fr: 'competence1_nomFr', en: 'competence1_nameEn' }, + index: '1', + description_i18n: { fr: 'competence1_descriptionFr', en: 'competence1_descriptionEn' }, + origin: 'Pix', + areaId: 'recArea1', + }); + const competence2DB = databaseBuilder.factory.learningContent.buildCompetence({ + id: 'recCompetence2', + name_i18n: { fr: 'competence2_nomFr', en: 'competence2_nameEn' }, + index: '2', + description_i18n: { fr: 'competence2_descriptionFr', en: 'competence2_descriptionEn' }, + origin: 'Pix', + areaId: 'recArea1', + }); + const competence3DB = databaseBuilder.factory.learningContent.buildCompetence({ + id: 'recCompetence3', + name_i18n: { fr: 'competence3_nomFr', en: 'competence3_nameEn' }, + index: '1', + description_i18n: { fr: 'competence3_descriptionFr', en: 'competence3_descriptionEn' }, + origin: 'Pix', + areaId: 'recArea2', + }); + const thematic1DB = databaseBuilder.factory.learningContent.buildThematic({ + id: 'recThematic1', + name_i18n: { + fr: 'thematique1_nomFr', + en: 'thematic1_nameEn', + }, + index: 10, + competenceId: 'recCompetence1', + tubeIds: ['recTube1'], + }); + const thematic2DB = databaseBuilder.factory.learningContent.buildThematic({ + id: 'recThematic2', + name_i18n: { + fr: 'thematique2_nomFr', + en: 'thematic2_nameEn', + }, + index: 20, + competenceId: 'recCompetence2', + tubeIds: ['recTube2', 'recTube3'], + }); + const thematic3DB = databaseBuilder.factory.learningContent.buildThematic({ + id: 'recThematic3', + name_i18n: { + fr: 'thematique3_nomFr', + en: 'thematic3_nameEn', + }, + index: 30, + competenceId: 'recCompetence3', + tubeIds: ['recTube4'], + }); + const tube1DB = databaseBuilder.factory.learningContent.buildTube({ + id: 'recTube1', + name: '@tube1_name', + title: 'tube1_title', + description: 'tube1_description', + practicalTitle_i18n: { fr: 'tube1_practicalTitleFr', en: 'tube1_practicalTitleEn' }, + practicalDescription_i18n: { + fr: 'tube1_practicalDescriptionFr', + en: 'tube1_practicalDescriptionEn', + }, + isMobileCompliant: true, + isTabletCompliant: false, + competenceId: 'recCompetence1', + thematicId: 'recThematic1', + }); + const tube2DB = databaseBuilder.factory.learningContent.buildTube({ + id: 'recTube2', + name: '@tube2_name', + title: '@tube2_title', + description: '@tube2_description', + practicalTitle_i18n: { fr: 'tube2_practicalTitleFr', en: 'tube2_practicalTitleEn' }, + practicalDescription_i18n: { + fr: 'tube2_practicalDescriptionFr', + en: 'tube2_practicalDescriptionEn', + }, + isMobileCompliant: false, + isTabletCompliant: true, + competenceId: 'recCompetence2', + thematicId: 'recThematic2', + }); + const tube3DB = databaseBuilder.factory.learningContent.buildTube({ + id: 'recTube3', + name: '@tube3_name', + title: '@tube3_title', + description: '@tube3_description', + practicalTitle_i18n: { fr: 'tube3_practicalTitleFr', en: 'tube3_practicalTitleEn' }, + practicalDescription_i18n: { + fr: 'tube3_practicalDescriptionFr', + en: 'tube3_practicalDescriptionEn', + }, + isMobileCompliant: true, + isTabletCompliant: true, + competenceId: 'recCompetence2', + thematicId: 'recThematic2', + }); + const tube4DB = databaseBuilder.factory.learningContent.buildTube({ + id: 'recTube4', + name: '@tube4_name', + title: 'tube4_title', + description: 'tube4_description', + practicalTitle_i18n: { fr: 'tube4_practicalTitleFr', en: 'tube4_practicalTitleEn' }, + practicalDescription_i18n: { + fr: 'tube4_practicalDescriptionFr', + en: 'tube4_practicalDescriptionEn', + }, + isMobileCompliant: false, + isTabletCompliant: false, + competenceId: 'recCompetence3', + thematicId: 'recThematic3', + }); + const skill1DB = databaseBuilder.factory.learningContent.buildSkill({ + id: 'recSkill1', + name: '@tube1_name4', + status: 'actif', + level: 4, + pixValue: 12, + version: 98, + tubeId: 'recTube1', + }); + const skill2DB = databaseBuilder.factory.learningContent.buildSkill({ + id: 'recSkill2', + name: '@tube2_name1', + status: 'actif', + level: 1, + pixValue: 34, + version: 76, + tubeId: 'recTube2', + }); + const skill3DB = databaseBuilder.factory.learningContent.buildSkill({ + id: 'recSkill3', + name: '@tube2_name2', + status: 'archivé', + level: 2, + pixValue: 56, + version: 54, + tubeId: 'recTube2', + }); + const skill4DB = databaseBuilder.factory.learningContent.buildSkill({ + id: 'recSkill4', + status: 'périmé', + tubeId: 'recTube2', + }); + const skill5DB = databaseBuilder.factory.learningContent.buildSkill({ + id: 'recSkill5', + name: '@tube3_name5', + status: 'archivé', + level: 5, + pixValue: 44, + version: 55, + tubeId: 'recTube3', + }); + const skill6DB = databaseBuilder.factory.learningContent.buildSkill({ + id: 'recSkill6', + status: 'périmé', + tubeId: 'recTube3', + }); + const skill7DB = databaseBuilder.factory.learningContent.buildSkill({ + id: 'recSkill7', + status: 'périmé', + tubeId: 'recTube3', + }); + const skill8DB = databaseBuilder.factory.learningContent.buildSkill({ + id: 'recSkill8', + name: '@tube4_name8', + status: 'actif', + level: 7, + pixValue: 78, + version: 32, + tubeId: 'recTube4', + }); + await databaseBuilder.commit(); + + [framework1Fr, framework2Fr] = _buildDomainFrameworksFromDB([framework1DB, framework2DB]); + [framework1En, framework2En] = _buildDomainFrameworksFromDB([framework1DB, framework2DB]); + [area1Fr, area2Fr] = _buildDomainAreasFromDB([area1DB, area2DB], 'fr'); + [area1En, area2En] = _buildDomainAreasFromDB([area1DB, area2DB], 'en'); + [competence1Fr, competence2Fr, competence3Fr] = _buildDomainCompetencesFromDB( + [competence1DB, competence2DB, competence3DB], + 'fr', + ); + [competence1En, competence2En, competence3En] = _buildDomainCompetencesFromDB( + [competence1DB, competence2DB, competence3DB], + 'en', + ); + [thematic1Fr, thematic2Fr, thematic3Fr] = _buildDomainThematicsFromDB( + [thematic1DB, thematic2DB, thematic3DB], + 'fr', + ); + [thematic1En, thematic2En, thematic3En] = _buildDomainThematicsFromDB( + [thematic1DB, thematic2DB, thematic3DB], + 'en', + ); + [tube1Fr, tube2Fr, , tube4Fr] = _buildDomainTubesFromDB([tube1DB, tube2DB, tube3DB, tube4DB], 'fr'); + [tube1En, tube2En, , tube4En] = _buildDomainTubesFromDB([tube1DB, tube2DB, tube3DB, tube4DB], 'en'); + [skill1Fr, skill2Fr, skill3Fr, , , , , skill8Fr] = _buildDomainSkillsFromDB( + [skill1DB, skill2DB, skill3DB, skill4DB, skill5DB, skill6DB, skill7DB, skill8DB], + 'fr', + ); }); - describe('#find', function () { - describe('when no database errors', function () { - it('should return matched entities', async function () { - // given - const group = 'group1'; - const cacheKey = 'findByGroup(group1)'; - const callback = (knex) => knex.where({ group }).orderBy('id'); - - // when - const dtos = await repository.find(cacheKey, callback); - - // then - expect(dtos).to.deep.equal([ - { id: 'entity1', name: 'Entity 1', group: 'group1' }, - { id: 'entity2', name: 'Entity 2', group: 'group1' }, - { id: 'entity3', name: 'Entity 3', group: 'group1' }, - ]); - expect(queryHook).to.have.been.calledTwice; - }); - - describe('when result is cached', function () { - it('should return entities from cache', async function () { - // given - const group = 'group1'; - const cacheKey = 'findByGroup(group1)'; - const callback = (knex) => knex.where({ group }).orderBy('id'); - await repository.find(cacheKey, callback); - queryHook.reset(); - - // when - const dtos = await repository.find(cacheKey, callback); - - // then - expect(dtos).to.deep.equal([ - { id: 'entity1', name: 'Entity 1', group: 'group1' }, - { id: 'entity2', name: 'Entity 2', group: 'group1' }, - { id: 'entity3', name: 'Entity 3', group: 'group1' }, - ]); - expect(queryHook).not.to.have.been.called; - }); - }); + describe('#findByCampaignId', function () { + let campaignId; + + it('should return frameworks, areas, competences, thematics and tubes of the skills hierarchy', async function () { + // given + campaignId = databaseBuilder.factory.buildCampaign().id; + ['recSkill2', 'recSkill3'].forEach((skillId) => + databaseBuilder.factory.buildCampaignSkill({ campaignId, skillId }), + ); + await databaseBuilder.commit(); + + framework1Fr.areas = [area1Fr]; + area1Fr.competences = [competence2Fr]; + competence2Fr.thematics = [thematic2Fr]; + competence2Fr.tubes = [tube2Fr]; + thematic2Fr.tubes = [tube2Fr]; + tube2Fr.skills = [skill2Fr, skill3Fr]; + + // when + const learningContentFromCampaign = await learningContentRepository.findByCampaignId(campaignId); + + // then + expect(learningContentFromCampaign.areas).to.deep.equal([area1Fr]); + expect(learningContentFromCampaign.frameworks).to.deep.equal([framework1Fr]); }); - describe('when database error in find ids query', function () { - it('should throw an Error', async function () { - // given - const group = 'group1'; - const cacheKey = 'findByGroup(group1)'; - const callback = (knex) => knex.where({ group }).orderBy('id'); - queryHook.onFirstCall().throws(new Error()); - - // when - const err = await catchErr((...args) => repository.find(...args))(cacheKey, callback); - - // then - expect(err).to.be.instanceOf(Error); - expect(queryHook).to.have.been.calledOnce; - }); + it('should translate names and descriptions when specifying a locale', async function () { + // given + campaignId = databaseBuilder.factory.buildCampaign().id; + ['recSkill2', 'recSkill3'].forEach((skillId) => + databaseBuilder.factory.buildCampaignSkill({ campaignId, skillId }), + ); + await databaseBuilder.commit(); + + framework1En.areas = [area1En]; + area1En.competences = [competence2En]; + competence2En.thematics = [thematic2En]; + competence2En.tubes = [tube2En]; + thematic2En.tubes = [tube2En]; + tube2En.skills = [skill2Fr, skill3Fr]; + + // when + const learningContentFromCampaign = await learningContentRepository.findByCampaignId(campaignId, 'en'); + + // then + expect(learningContentFromCampaign.frameworks).to.deep.equal([framework1En]); }); - describe('when database error in load entities query', function () { - it('should throw an Error', async function () { - // given - const group = 'group1'; - const cacheKey = 'findByGroup(group1)'; - const callback = (knex) => knex.where({ group }).orderBy('id'); - queryHook.onSecondCall().throws(new Error()); + it('should throw a NoSkillsInCampaignError when there are no more operative skills', async function () { + // given + campaignId = databaseBuilder.factory.buildCampaign().id; + databaseBuilder.factory.buildCampaignSkill({ campaignId, skillId: 'recSkill4' }); + await databaseBuilder.commit(); - // when - const err = await catchErr((...args) => repository.find(...args))(cacheKey, callback); + // when + const err = await catchErr(learningContentRepository.findByCampaignId)(campaignId); - // then - expect(err).to.be.instanceOf(Error); - expect(queryHook).to.have.been.calledTwice; - }); + // then + expect(err).to.be.instanceOf(NoSkillsInCampaignError); }); }); - describe('#loadMany', function () { - describe('when no database errors', function () { - it('should return entities', async function () { + describe('#findByTargetProfileId', function () { + context('when target profile does not have capped tubes', function () { + it('should throw a NotFound error', async function () { // given - const ids = ['entity4', 'entity1', 'entity5']; + const targetProfileId = databaseBuilder.factory.buildTargetProfile().id; + const anotherTargetProfileId = databaseBuilder.factory.buildTargetProfile().id; + databaseBuilder.factory.buildTargetProfileTube({ targetProfileId: anotherTargetProfileId }); + await databaseBuilder.commit(); // when - const dtos = await repository.loadMany(ids); + const error = await catchErr(learningContentRepository.findByTargetProfileId)(targetProfileId); // then - expect(dtos).to.deep.equal([ - { id: 'entity4', name: 'Entity 4', group: 'group2' }, - { id: 'entity1', name: 'Entity 1', group: 'group1' }, - { id: 'entity5', name: 'Entity 5', group: 'group2' }, - ]); - expect(queryHook).to.have.been.calledOnce; - }); - - describe('when result is cached', function () { - it('should return entities from cache', async function () { - // given - const ids = ['entity4', 'entity1', 'entity5']; - await repository.loadMany(ids); - queryHook.reset(); - - // when - const dtos = await repository.loadMany(ids); - - // then - expect(dtos).to.deep.equal([ - { id: 'entity4', name: 'Entity 4', group: 'group2' }, - { id: 'entity1', name: 'Entity 1', group: 'group1' }, - { id: 'entity5', name: 'Entity 5', group: 'group2' }, - ]); - expect(queryHook).not.to.have.been.called; - }); + expect(error).to.be.instanceOf(NotFoundError); + expect(error.message).to.equal("Le profil cible n'existe pas"); }); }); - describe('when database error', function () { - it('should throw an Error', async function () { + context('when target profile has capped tubes', function () { + it('should return frameworks, areas, competences, thematics and tubes of the active skills hierarchy with default FR language', async function () { // given - const ids = ['entity4', 'entity1', 'entity5']; - queryHook.onFirstCall().throws(new Error()); - - // when - const err = await catchErr((...args) => repository.loadMany(...args))(ids); + const targetProfileId = databaseBuilder.factory.buildTargetProfile().id; + databaseBuilder.factory.buildTargetProfileTube({ targetProfileId, tubeId: 'recTube2', level: 2 }); + await databaseBuilder.commit(); - // then - expect(err).to.be.instanceOf(Error); - expect(queryHook).to.have.been.calledOnce; - }); - }); - }); - - describe('#load', function () { - describe('when no database errors', function () { - it('should return entity', async function () { - // given - const id = 'entity3'; + framework1Fr.areas = [area1Fr]; + area1Fr.competences = [competence2Fr]; + competence2Fr.thematics = [thematic2Fr]; + competence2Fr.tubes = [tube2Fr]; + thematic2Fr.tubes = [tube2Fr]; + tube2Fr.skills = [skill2Fr]; // when - const dto = await repository.load(id); + const targetProfileLearningContent = await learningContentRepository.findByTargetProfileId(targetProfileId); // then - expect(dto).to.deep.equal({ id: 'entity3', name: 'Entity 3', group: 'group1' }); - expect(queryHook).to.have.been.calledOnce; + expect(targetProfileLearningContent.frameworks).to.deep.equal([framework1Fr]); }); - describe('when result is cached', function () { - it('should return entity from cache', async function () { + context('when using a specific locale', function () { + it('should translate names and descriptions', async function () { // given - const id = 'entity3'; - await repository.load(id); - queryHook.reset(); + const targetProfileId = databaseBuilder.factory.buildTargetProfile().id; + databaseBuilder.factory.buildTargetProfileTube({ targetProfileId, tubeId: 'recTube2', level: 2 }); + await databaseBuilder.commit(); + + framework1En.areas = [area1En]; + area1En.competences = [competence2En]; + competence2En.thematics = [thematic2En]; + competence2En.tubes = [tube2En]; + thematic2En.tubes = [tube2En]; + tube2En.skills = [skill2Fr]; // when - const dto = await repository.load(id); + const targetProfileLearningContent = await learningContentRepository.findByTargetProfileId( + targetProfileId, + 'en', + ); // then - expect(dto).to.deep.equal({ id: 'entity3', name: 'Entity 3', group: 'group1' }); - expect(queryHook).not.to.have.been.called; + expect(targetProfileLearningContent.frameworks).to.deep.equal([framework1En]); }); }); }); - - describe('when database error', function () { - it('should throw an Error', async function () { - // given - const id = 'entity3'; - queryHook.onFirstCall().throws(new Error()); - - // when - const err = await catchErr((...args) => repository.load(...args))(id); - - // then - expect(err).to.be.instanceOf(Error); - expect(queryHook).to.have.been.calledOnce; - }); - }); }); - describe('getMany', function () { - describe('when no database errors', function () { - it('should return entities', async function () { - // given - const ids = ['entity4', null, 'entity1', 'entity4', undefined, 'entity5', 'entity5']; - - // when - const dtos = await repository.getMany(ids); - - // then - expect(dtos).to.deep.equal([ - { id: 'entity4', name: 'Entity 4', group: 'group2' }, - { id: 'entity1', name: 'Entity 1', group: 'group1' }, - { id: 'entity5', name: 'Entity 5', group: 'group2' }, - ]); - expect(queryHook).to.have.been.calledOnce; + describe('#findByFrameworkNames', function () { + it('should return an active LearningContent with the frameworks designated by name', async function () { + // given + framework1Fr.areas = [area1Fr]; + framework2Fr.areas = [area2Fr]; + area1Fr.competences = [competence1Fr, competence2Fr]; + area2Fr.competences = [competence3Fr]; + competence1Fr.thematics = [thematic1Fr]; + competence1Fr.tubes = [tube1Fr]; + competence2Fr.thematics = [thematic2Fr]; + competence2Fr.tubes = [tube2Fr]; + competence3Fr.thematics = [thematic3Fr]; + competence3Fr.tubes = [tube4Fr]; + thematic1Fr.tubes = [tube1Fr]; + thematic2Fr.tubes = [tube2Fr]; + thematic3Fr.tubes = [tube4Fr]; + tube1Fr.skills = [skill1Fr]; + tube2Fr.skills = [skill2Fr]; + tube4Fr.skills = [skill8Fr]; + + // when + const learningContent = await learningContentRepository.findByFrameworkNames({ + frameworkNames: ['Mon référentiel 1', 'Mon référentiel 2'], }); - describe('when result is cached', function () { - it('should return entities from cache', async function () { - // given - const ids = ['entity4', null, 'entity1', 'entity4', undefined, 'entity5', 'entity5']; - await repository.getMany(ids); - queryHook.reset(); - - // when - const dtos = await repository.getMany(ids); + // then + const expectedLearningContent = domainBuilder.buildLearningContent([framework1Fr, framework2Fr]); + expect(learningContent).to.deepEqualInstance(expectedLearningContent); + }); - // then - expect(dtos).to.deep.equal([ - { id: 'entity4', name: 'Entity 4', group: 'group2' }, - { id: 'entity1', name: 'Entity 1', group: 'group1' }, - { id: 'entity5', name: 'Entity 5', group: 'group2' }, - ]); - expect(queryHook).not.to.have.been.called; - }); + it('should return an active LearningContent in the given language', async function () { + // given + framework1En.areas = [area1En]; + framework2En.areas = [area2En]; + area1En.competences = [competence1En, competence2En]; + area2En.competences = [competence3En]; + competence1En.thematics = [thematic1En]; + competence1En.tubes = [tube1En]; + competence2En.thematics = [thematic2En]; + competence2En.tubes = [tube2En]; + competence3En.thematics = [thematic3En]; + competence3En.tubes = [tube4En]; + thematic1En.tubes = [tube1En]; + thematic2En.tubes = [tube2En]; + thematic3En.tubes = [tube4En]; + tube1En.skills = [skill1Fr]; + tube2En.skills = [skill2Fr]; + tube4En.skills = [skill8Fr]; + + // when + const learningContent = await learningContentRepository.findByFrameworkNames({ + frameworkNames: ['Mon référentiel 1', 'Mon référentiel 2'], + locale: 'en', }); + + // then + const expectedLearningContent = domainBuilder.buildLearningContent([framework1En, framework2En]); + expect(learningContent).to.deepEqualInstance(expectedLearningContent); }); }); }); + +function _buildDomainFrameworksFromDB(frameworksDB) { + return frameworksDB.map((frameworkDB) => + domainBuilder.buildFramework({ + id: frameworkDB.id, + name: frameworkDB.name, + areas: [], + }), + ); +} + +function _buildDomainAreasFromDB(areasDB, locale) { + return areasDB.map((areaDB) => + domainBuilder.buildArea({ + ...areaDB, + title: areaDB.title_i18n[locale], + }), + ); +} + +function _buildDomainCompetencesFromDB(competencesDB, locale) { + return competencesDB.map((competenceDB) => + domainBuilder.buildCompetence({ + ...competenceDB, + name: competenceDB.name_i18n[locale], + description: competenceDB.description_i18n[locale], + }), + ); +} + +function _buildDomainThematicsFromDB(thematicsDB, locale) { + return thematicsDB.map((thematicDB) => + domainBuilder.buildThematic({ + ...thematicDB, + name: thematicDB.name_i18n[locale], + }), + ); +} + +function _buildDomainTubesFromDB(tubesDB, locale) { + return tubesDB.map((tubeDB) => + domainBuilder.buildTube({ + ...tubeDB, + practicalTitle: tubeDB.practicalTitle_i18n[locale], + practicalDescription: tubeDB.practicalDescription_i18n[locale], + }), + ); +} + +function _buildDomainSkillsFromDB(skillsDB, locale) { + return skillsDB.map((skillDB) => + domainBuilder.buildSkill({ ...skillDB, difficulty: skillDB.level, hint: skillDB.hint_i18n[locale] }), + ); +}