diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index 8a6629f0..05b96755 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -24,6 +24,14 @@ Een duidelijke, beknopte beschrijving van wat je verwacht dat er gebeurt. Indien van toepassing, voeg een screenshot toe die het probleem duidelijk maakt. **Extra context** -Voeg extra context over het probleem toe. Was je ergens bijzonder mee bezig of naar op zoek? +Was je ergens bijzonder mee bezig of naar op zoek? Welke documentatie of links heb je (al) geraadpleegd? -- [ ] Ik heb aan deze issue het juiste label toegekend, afhankelijk van frontend, backend, ... + diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md index b99513ab..dd1ed988 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -6,7 +6,7 @@ labels: enhancement assignees: '' --- -**Is your feature request related to a problem? Please describe.** +**Is jouw feature request gerelateerd tot een probleem? Beschrijf.** Een duidelijke, beknopte beschrijving van het probleem. Wat mist er? Wat kan beter? **Beschrijf de oplossing die je zou willen** @@ -15,4 +15,11 @@ Een duidelijke, beknopte beschrijving van wat je zou willen dat er gebeurt. **Extra context** Extra context of screenshots bij de feature. -- [ ] Ik heb aan deze issue het juiste label toegekend, afhankelijk van frontend, backend, ... + diff --git a/README.md b/README.md index fcc4d3ba..dc09bbfc 100644 --- a/README.md +++ b/README.md @@ -9,29 +9,26 @@ Figma Projectopgave

- - Dit is de monorepo voor [Dwengo-1](https://sel2-1.ugent.be), een interactief leerplatform waar leerkrachten opdrachten en lessen kunnen samenstellen hun leerlingen en hun vooruitgang kunnen opvolgen. ## Installatie -Om de applicatie in te stellen voor een productieomgeving, volg de [installatiehandleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving). +Om de applicatie in te stellen voor een productieomgeving, volg +de [installatiehandleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving). Alternatief kan je één van de volgende methodes gebruiken om de applicatie lokaal te draaien. ### Quick start -1. Installeer Docker en Docker Compose op je systeem (zie [Docker](https://docs.docker.com/get-docker/) en [Docker Compose](https://docs.docker.com/compose/)). +1. Installeer Docker en Docker Compose op je systeem (zie [Docker](https://docs.docker.com/get-docker/) + en [Docker Compose](https://docs.docker.com/compose/)). 2. Clone deze repository. -3. In de backend, kopieer `.env.example` (of `.env.development.example`) naar `.env` en pas de variabelen aan waar nodig. +3. In de backend, kopieer `.env.example` (of `.env.development.example`) naar `.env` en pas de variabelen aan waar + nodig. 4. Voer `docker compose up` uit in de root van de repository. -5. Optioneel: Configureer de applicatie aan de hand van de [configuratiehandleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving#dwengo-1-configuratie). +5. Optioneel: Configureer de applicatie aan de hand van + de [configuratiehandleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving#dwengo-1-configuratie). ```bash docker compose version @@ -47,7 +44,8 @@ docker compose up ### Handmatige installatie -Zie de submappen voor de installatie-instructies van de [frontend](./frontend/README.md) en [backend](./backend/README.md). +Zie de submappen voor de installatie-instructies van de [frontend](./frontend/README.md) +en [backend](./backend/README.md). ## Architectuur @@ -59,8 +57,31 @@ De tech-stack bestaat uit: - **Backend**: TypeScript + Node.js + Express.js + TypeORM + PostgreSQL - **Identity provider**: Keycloak -Voor meer informatie over de keuze van deze tech-stack, zie [designkeuzes](https://github.com/SELab-2/Dwengo-1/wiki/Developer:-Design-keuzes). +Voor meer informatie over de keuze van deze tech-stack, +zie [designkeuzes](https://github.com/SELab-2/Dwengo-1/wiki/Developer:-Design-keuzes). + +## Testen + +Voer volgende commando's uit om de te testen: + +``` +npm run test:unit +``` ## Bijdragen aan Dwengo-1 Zie [CONTRIBUTING.md](./CONTRIBUTING.md) voor meer informatie over hoe je kan bijdragen aan Dwengo-1. + +Deze rocksterren hebben bijgedragen aan Dwengo-1: + +| Naam | Functie | +| ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | +| [
Adriaan Jacquet](https://github.com/WhisperinCheetah) | Backend Lead | +| [
Francisco Gabriel Van Langenhove](https://github.com/Gabriellvl) | Team Lead | +| [
Gerald Schmittinger](https://github.com/geraldschmittinger) | Database Administrator | +| [
Joyelle Ndagijimana](https://github.com/joyelle436) | Frontend Lead | +| [
Laure Jablonski](https://github.com/laurejablonski) | Documentatie- en Test Lead | +| [
Tibo De Peuter](https://github.com/tdpeuter) | Technische Lead | +| [
Timo De Meyst](https://github.com/kloep1) | System Administrator | + +En in de toekomst misschien jij ook? diff --git a/backend/README.md b/backend/README.md index 4b2083b6..442cea82 100644 --- a/backend/README.md +++ b/backend/README.md @@ -21,6 +21,14 @@ npm run build npm run start ``` +### Tests + +Voer volgend commando uit om de unit tests uit te voeren: + +``` +npm run test:unit +``` + ## Keycloak configuratie Tijdens development is het voldoende om gebruik te maken van de keycloak configuratie die automatisch ingeladen wordt. diff --git a/backend/eslint.config.ts b/backend/eslint.config.ts index f5f225b2..6b696021 100644 --- a/backend/eslint.config.ts +++ b/backend/eslint.config.ts @@ -8,4 +8,14 @@ export default [ globals: globals.node, }, }, + + { + files: ['tests/**/*.ts'], + languageOptions: { + globals: globals.node, + }, + rules: { + 'no-console': 'off', + }, + }, ]; diff --git a/backend/package.json b/backend/package.json index 37d21532..d548b52f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,40 +5,46 @@ "private": true, "type": "module", "scripts": { - "build": "NODE_ENV=production tsc --project tsconfig.json", - "dev": "NODE_ENV=development tsx watch --env-file=.env.development.local src/app.ts", - "start": "NODE_ENV=production node --env-file=.env dist/app.js", + "build": "cross-env NODE_ENV=production tsc --project tsconfig.json", + "dev": "cross-env NODE_ENV=development tsx watch --env-file=.env.development.local src/app.ts", + "start": "cross-env NODE_ENV=production node --env-file=.env dist/app.js", "format": "prettier --write src/", "format-check": "prettier --check src/", "lint": "eslint . --fix", "test:unit": "vitest" }, "dependencies": { - "@mikro-orm/core": "6.4.6", - "@mikro-orm/postgresql": "6.4.6", - "@mikro-orm/reflection": "6.4.6", - "@mikro-orm/sqlite": "6.4.6", - "@types/js-yaml": "^4.0.9", + "@mikro-orm/core": "6.4.9", + "@mikro-orm/knex": "6.4.9", + "@mikro-orm/postgresql": "6.4.9", + "@mikro-orm/reflection": "6.4.9", + "@mikro-orm/sqlite": "6.4.9", "axios": "^1.8.2", + "cors": "^2.8.5", + "cross": "^1.0.0", + "cross-env": "^7.0.3", "dotenv": "^16.4.7", "express": "^5.0.1", "express-jwt": "^8.5.1", - "jwks-rsa": "^3.1.0", - "uuid": "^11.1.0", + "gift-pegjs": "^1.0.2", + "isomorphic-dompurify": "^2.22.0", "js-yaml": "^4.1.0", + "jsonpath-plus": "^10.3.0", + "jwks-rsa": "^3.1.0", "loki-logger-ts": "^1.0.2", + "marked": "^15.0.7", "response-time": "^2.3.3", + "uuid": "^11.1.0", "winston": "^3.17.0", - "winston-loki": "^6.1.3", - "cors": "^2.8.5", - "@types/cors": "^2.8.17" + "winston-loki": "^6.1.3" }, "devDependencies": { - "@mikro-orm/cli": "6.4.6", + "@mikro-orm/cli": "6.4.9", + "@types/cors": "^2.8.17", "@types/express": "^5.0.0", + "@types/js-yaml": "^4.0.9", "@types/node": "^22.13.4", "@types/response-time": "^2.3.8", - "@types/js-yaml": "^4.0.9", "globals": "^15.15.0", "ts-node": "^10.9.2", "tsx": "^4.19.3", diff --git a/backend/src/app.ts b/backend/src/app.ts index 55352220..436af86f 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -2,8 +2,8 @@ import express, { Express, Response } from 'express'; import { initORM } from './orm.js'; import themeRoutes from './routes/themes.js'; -import learningPathRoutes from './routes/learningPaths.js'; -import learningObjectRoutes from './routes/learningObjects.js'; +import learningPathRoutes from './routes/learning-paths.js'; +import learningObjectRoutes from './routes/learning-objects.js'; import studentRouter from './routes/student.js'; import groupRouter from './routes/group.js'; diff --git a/backend/src/config.ts b/backend/src/config.ts index ad8cf0be..6cf388cc 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -1,10 +1,9 @@ -export const FALLBACK_LANG: string = 'nl'; +import { EnvVars, getEnvVar } from './util/envvars.js'; // API - -export const DWENGO_API_BASE: string = 'https://dwengo.org/backend/api'; +export const DWENGO_API_BASE = getEnvVar(EnvVars.LearningContentRepoApiBaseUrl); +export const FALLBACK_LANG = getEnvVar(EnvVars.FallbackLanguage); // Logging - export const LOG_LEVEL: string = 'development' === process.env.NODE_ENV ? 'debug' : 'info'; export const LOKI_HOST: string = process.env.LOKI_HOST || 'http://localhost:3102'; diff --git a/backend/src/controllers/learning-objects.ts b/backend/src/controllers/learning-objects.ts new file mode 100644 index 00000000..455a4006 --- /dev/null +++ b/backend/src/controllers/learning-objects.ts @@ -0,0 +1,69 @@ +import { Request, Response } from 'express'; +import { FALLBACK_LANG } from '../config.js'; +import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../interfaces/learning-content.js'; +import learningObjectService from '../services/learning-objects/learning-object-service.js'; +import { EnvVars, getEnvVar } from '../util/envvars.js'; +import { Language } from '../entities/content/language.js'; +import { BadRequestException } from '../exceptions.js'; +import attachmentService from '../services/learning-objects/attachment-service.js'; +import { NotFoundError } from '@mikro-orm/core'; + +function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifier { + if (!req.params.hruid) { + throw new BadRequestException('HRUID is required.'); + } + return { + hruid: req.params.hruid as string, + language: (req.query.language || getEnvVar(EnvVars.FallbackLanguage)) as Language, + version: parseInt(req.query.version as string), + }; +} + +function getLearningPathIdentifierFromRequest(req: Request): LearningPathIdentifier { + if (!req.query.hruid) { + throw new BadRequestException('HRUID is required.'); + } + return { + hruid: req.params.hruid as string, + language: (req.query.language as Language) || FALLBACK_LANG, + }; +} + +export async function getAllLearningObjects(req: Request, res: Response): Promise { + const learningPathId = getLearningPathIdentifierFromRequest(req); + const full = req.query.full; + + let learningObjects: FilteredLearningObject[] | string[]; + if (full) { + learningObjects = await learningObjectService.getLearningObjectsFromPath(learningPathId); + } else { + learningObjects = await learningObjectService.getLearningObjectIdsFromPath(learningPathId); + } + + res.json(learningObjects); +} + +export async function getLearningObject(req: Request, res: Response): Promise { + const learningObjectId = getLearningObjectIdentifierFromRequest(req); + + const learningObject = await learningObjectService.getLearningObjectById(learningObjectId); + res.json(learningObject); +} + +export async function getLearningObjectHTML(req: Request, res: Response): Promise { + const learningObjectId = getLearningObjectIdentifierFromRequest(req); + + const learningObject = await learningObjectService.getLearningObjectHTML(learningObjectId); + res.send(learningObject); +} + +export async function getAttachment(req: Request, res: Response): Promise { + const learningObjectId = getLearningObjectIdentifierFromRequest(req); + const name = req.params.attachmentName; + const attachment = await attachmentService.getAttachment(learningObjectId, name); + + if (!attachment) { + throw new NotFoundError(`Attachment ${name} not found`); + } + res.setHeader('Content-Type', attachment.mimeType).send(attachment.content); +} diff --git a/backend/src/controllers/learning-paths.ts b/backend/src/controllers/learning-paths.ts new file mode 100644 index 00000000..37f92d91 --- /dev/null +++ b/backend/src/controllers/learning-paths.ts @@ -0,0 +1,64 @@ +import { Request, Response } from 'express'; +import { themes } from '../data/themes.js'; +import { FALLBACK_LANG } from '../config.js'; +import learningPathService from '../services/learning-paths/learning-path-service.js'; +import { BadRequestException, NotFoundException } from '../exceptions.js'; +import { Language } from '../entities/content/language.js'; +import { + PersonalizationTarget, + personalizedForGroup, + personalizedForStudent, +} from '../services/learning-paths/learning-path-personalization-util.js'; + +/** + * Fetch learning paths based on query parameters. + */ +export async function getLearningPaths(req: Request, res: Response): Promise { + const hruids = req.query.hruid; + const themeKey = req.query.theme as string; + const searchQuery = req.query.search as string; + const language = (req.query.language as string) || FALLBACK_LANG; + + const forStudent = req.query.forStudent as string; + const forGroupNo = req.query.forGroup as string; + const assignmentNo = req.query.assignmentNo as string; + const classId = req.query.classId as string; + + let personalizationTarget: PersonalizationTarget | undefined; + + if (forStudent) { + personalizationTarget = await personalizedForStudent(forStudent); + } else if (forGroupNo) { + if (!assignmentNo || !classId) { + throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.'); + } + personalizationTarget = await personalizedForGroup(classId, parseInt(assignmentNo), parseInt(forGroupNo)); + } + + let hruidList; + + if (hruids) { + hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)]; + } else if (themeKey) { + const theme = themes.find((t) => t.title === themeKey); + if (theme) { + hruidList = theme.hruids; + } else { + throw new NotFoundException(`Theme "${themeKey}" not found.`); + } + } else if (searchQuery) { + const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, personalizationTarget); + res.json(searchResults); + return; + } else { + hruidList = themes.flatMap((theme) => theme.hruids); + } + + const learningPaths = await learningPathService.fetchLearningPaths( + hruidList, + language as Language, + `HRUIDs: ${hruidList.join(', ')}`, + personalizationTarget + ); + res.json(learningPaths.data); +} diff --git a/backend/src/controllers/learningObjects.ts b/backend/src/controllers/learningObjects.ts index 0d41044b..e69de29b 100644 --- a/backend/src/controllers/learningObjects.ts +++ b/backend/src/controllers/learningObjects.ts @@ -1,48 +0,0 @@ -import { Request, Response } from 'express'; -import { getLearningObjectById, getLearningObjectIdsFromPath, getLearningObjectsFromPath } from '../services/learningObjects.js'; -import { FALLBACK_LANG } from '../config.js'; -import { FilteredLearningObject } from '../interfaces/learningPath.js'; -import { getLogger } from '../logging/initalize.js'; - -export async function getAllLearningObjects(req: Request, res: Response): Promise { - try { - const hruid = req.query.hruid as string; - const full = req.query.full === 'true'; - const language = (req.query.language as string) || FALLBACK_LANG; - - if (!hruid) { - res.status(400).json({ error: 'HRUID query is required.' }); - return; - } - - let learningObjects: FilteredLearningObject[] | string[]; - if (full) { - learningObjects = await getLearningObjectsFromPath(hruid, language); - } else { - learningObjects = await getLearningObjectIdsFromPath(hruid, language); - } - - res.json(learningObjects); - } catch (error) { - getLogger().error('Error fetching learning objects:', error); - res.status(500).json({ error: 'Internal server error' }); - } -} - -export async function getLearningObject(req: Request, res: Response): Promise { - try { - const { hruid } = req.params; - const language = (req.query.language as string) || FALLBACK_LANG; - - if (!hruid) { - res.status(400).json({ error: 'HRUID parameter is required.' }); - return; - } - - const learningObject = await getLearningObjectById(hruid, language); - res.json(learningObject); - } catch (error) { - getLogger().error('Error fetching learning object:', error); - res.status(500).json({ error: 'Internal server error' }); - } -} diff --git a/backend/src/controllers/learningPaths.ts b/backend/src/controllers/learningPaths.ts index 50299d0f..0a7ff0ae 100644 --- a/backend/src/controllers/learningPaths.ts +++ b/backend/src/controllers/learningPaths.ts @@ -1,8 +1,9 @@ import { Request, Response } from 'express'; import { themes } from '../data/themes.js'; import { FALLBACK_LANG } from '../config.js'; -import { fetchLearningPaths, searchLearningPaths } from '../services/learningPaths.js'; import { getLogger } from '../logging/initalize.js'; +import learningPathService from '../services/learning-paths/learning-path-service.js'; +import { Language } from '../entities/content/language.js'; /** * Fetch learning paths based on query parameters. */ @@ -11,7 +12,7 @@ export async function getLearningPaths(req: Request, res: Response): Promise theme.hruids); } - const learningPaths = await fetchLearningPaths(hruidList, language, `HRUIDs: ${hruidList.join(', ')}`); + const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language, `HRUIDs: ${hruidList.join(', ')}`); res.json(learningPaths.data); } catch (error) { getLogger().error('❌ Unexpected error fetching learning paths:', error); diff --git a/backend/src/data/content/attachment-repository.ts b/backend/src/data/content/attachment-repository.ts index bec874c9..95c5ab1c 100644 --- a/backend/src/data/content/attachment-repository.ts +++ b/backend/src/data/content/attachment-repository.ts @@ -1,13 +1,37 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { Attachment } from '../../entities/content/attachment.entity.js'; -import { LearningObject } from '../../entities/content/learning-object.entity.js'; +import { Language } from '../../entities/content/language'; +import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier'; export class AttachmentRepository extends DwengoEntityRepository { - public findByLearningObjectAndNumber(learningObject: LearningObject, sequenceNumber: number) { + public findByLearningObjectIdAndName(learningObjectId: LearningObjectIdentifier, name: string): Promise { return this.findOne({ - learningObject: learningObject, - sequenceNumber: sequenceNumber, + learningObject: { + hruid: learningObjectId.hruid, + language: learningObjectId.language, + version: learningObjectId.version, + }, + name: name, }); } + + public findByMostRecentVersionOfLearningObjectAndName(hruid: string, language: Language, attachmentName: string): Promise { + return this.findOne( + { + learningObject: { + hruid: hruid, + language: language, + }, + name: attachmentName, + }, + { + orderBy: { + learningObject: { + version: 'DESC', + }, + }, + } + ); + } // This repository is read-only for now since creating own learning object is an extension feature. } diff --git a/backend/src/data/content/learning-object-repository.ts b/backend/src/data/content/learning-object-repository.ts index a0d74184..f9b6bfcb 100644 --- a/backend/src/data/content/learning-object-repository.ts +++ b/backend/src/data/content/learning-object-repository.ts @@ -1,14 +1,34 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { LearningObject } from '../../entities/content/learning-object.entity.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; +import { Language } from '../../entities/content/language'; export class LearningObjectRepository extends DwengoEntityRepository { public findByIdentifier(identifier: LearningObjectIdentifier): Promise { - return this.findOne({ - hruid: identifier.hruid, - language: identifier.language, - version: identifier.version, - }); + return this.findOne( + { + hruid: identifier.hruid, + language: identifier.language, + version: identifier.version, + }, + { + populate: ['keywords'], + } + ); + } + + public findLatestByHruidAndLanguage(hruid: string, language: Language) { + return this.findOne( + { + hruid: hruid, + language: language, + }, + { + populate: ['keywords'], + orderBy: { + version: 'DESC', + }, + } + ); } - // This repository is read-only for now since creating own learning object is an extension feature. } diff --git a/backend/src/data/content/learning-path-repository.ts b/backend/src/data/content/learning-path-repository.ts index ab1f1b46..a2f9b47e 100644 --- a/backend/src/data/content/learning-path-repository.ts +++ b/backend/src/data/content/learning-path-repository.ts @@ -4,7 +4,23 @@ import { Language } from '../../entities/content/language.js'; export class LearningPathRepository extends DwengoEntityRepository { public findByHruidAndLanguage(hruid: string, language: Language): Promise { - return this.findOne({ hruid: hruid, language: language }); + return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions'] }); + } + + /** + * Returns all learning paths which have the given language and whose title OR description contains the + * query string. + * + * @param query The query string we want to seach for in the title or description. + * @param language The language of the learning paths we want to find. + */ + public async findByQueryStringAndLanguage(query: string, language: Language): Promise { + return this.findAll({ + where: { + language: language, + $or: [{ title: { $like: `%${query}%` } }, { description: { $like: `%${query}%` } }], + }, + populate: ['nodes', 'nodes.transitions'], + }); } - // This repository is read-only for now since creating own learning object is an extension feature. } diff --git a/backend/src/data/questions/answer-repository.ts b/backend/src/data/questions/answer-repository.ts index bab34a65..a28342bd 100644 --- a/backend/src/data/questions/answer-repository.ts +++ b/backend/src/data/questions/answer-repository.ts @@ -5,10 +5,12 @@ import { Teacher } from '../../entities/users/teacher.entity.js'; export class AnswerRepository extends DwengoEntityRepository { public createAnswer(answer: { toQuestion: Question; author: Teacher; content: string }): Promise { - const answerEntity = new Answer(); - answerEntity.toQuestion = answer.toQuestion; - answerEntity.author = answer.author; - answerEntity.content = answer.content; + const answerEntity = this.create({ + toQuestion: answer.toQuestion, + author: answer.author, + content: answer.content, + timestamp: new Date(), + }); return this.insert(answerEntity); } public findAllAnswersToQuestion(question: Question): Promise { diff --git a/backend/src/data/questions/question-repository.ts b/backend/src/data/questions/question-repository.ts index d41cc490..4099c528 100644 --- a/backend/src/data/questions/question-repository.ts +++ b/backend/src/data/questions/question-repository.ts @@ -5,7 +5,14 @@ import { Student } from '../../entities/users/student.entity.js'; export class QuestionRepository extends DwengoEntityRepository { public createQuestion(question: { loId: LearningObjectIdentifier; author: Student; content: string }): Promise { - const questionEntity = new Question(); + const questionEntity = this.create({ + learningObjectHruid: question.loId.hruid, + learningObjectLanguage: question.loId.language, + learningObjectVersion: question.loId.version, + author: question.author, + content: question.content, + timestamp: new Date(), + }); questionEntity.learningObjectHruid = question.loId.hruid; questionEntity.learningObjectLanguage = question.loId.language; questionEntity.learningObjectVersion = question.loId.version; diff --git a/backend/src/data/repositories.ts b/backend/src/data/repositories.ts index 6d67a693..3daa026d 100644 --- a/backend/src/data/repositories.ts +++ b/backend/src/data/repositories.ts @@ -28,6 +28,8 @@ import { LearningPath } from '../entities/content/learning-path.entity.js'; import { LearningPathRepository } from './content/learning-path-repository.js'; import { AttachmentRepository } from './content/attachment-repository.js'; import { Attachment } from '../entities/content/attachment.entity.js'; +import { LearningPathNode } from '../entities/content/learning-path-node.entity.js'; +import { LearningPathTransition } from '../entities/content/learning-path-transition.entity.js'; let entityManager: EntityManager | undefined; @@ -59,7 +61,7 @@ export const getTeacherRepository = repositoryGetter /* Classes */ export const getClassRepository = repositoryGetter(Class); export const getClassJoinRequestRepository = repositoryGetter(ClassJoinRequest); -export const getTeacherInvitationRepository = repositoryGetter(TeacherInvitationRepository); +export const getTeacherInvitationRepository = repositoryGetter(TeacherInvitation); /* Assignments */ export const getAssignmentRepository = repositoryGetter(Assignment); @@ -73,4 +75,6 @@ export const getAnswerRepository = repositoryGetter(An /* Learning content */ export const getLearningObjectRepository = repositoryGetter(LearningObject); export const getLearningPathRepository = repositoryGetter(LearningPath); -export const getAttachmentRepository = repositoryGetter(Assignment); +export const getLearningPathNodeRepository = repositoryGetter(LearningPathNode); +export const getLearningPathTransitionRepository = repositoryGetter(LearningPathTransition); +export const getAttachmentRepository = repositoryGetter(Attachment); diff --git a/backend/src/entities/assignments/assignment.entity.ts b/backend/src/entities/assignments/assignment.entity.ts index d1a04c5d..cda27d66 100644 --- a/backend/src/entities/assignments/assignment.entity.ts +++ b/backend/src/entities/assignments/assignment.entity.ts @@ -2,8 +2,9 @@ import { Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro import { Class } from '../classes/class.entity.js'; import { Group } from './group.entity.js'; import { Language } from '../content/language.js'; +import { AssignmentRepository } from '../../data/assignments/assignment-repository.js'; -@Entity() +@Entity({ repository: () => AssignmentRepository }) export class Assignment { @ManyToOne({ entity: () => Class, primary: true }) within!: Class; diff --git a/backend/src/entities/assignments/group.entity.ts b/backend/src/entities/assignments/group.entity.ts index d8142bdb..632ad722 100644 --- a/backend/src/entities/assignments/group.entity.ts +++ b/backend/src/entities/assignments/group.entity.ts @@ -1,8 +1,9 @@ import { Entity, ManyToMany, ManyToOne, PrimaryKey } from '@mikro-orm/core'; import { Assignment } from './assignment.entity.js'; import { Student } from '../users/student.entity.js'; +import { GroupRepository } from '../../data/assignments/group-repository.js'; -@Entity() +@Entity({ repository: () => GroupRepository }) export class Group { @ManyToOne({ entity: () => Assignment, diff --git a/backend/src/entities/assignments/submission.entity.ts b/backend/src/entities/assignments/submission.entity.ts index 77e01da8..f6f8b3c7 100644 --- a/backend/src/entities/assignments/submission.entity.ts +++ b/backend/src/entities/assignments/submission.entity.ts @@ -2,8 +2,9 @@ import { Student } from '../users/student.entity.js'; import { Group } from './group.entity.js'; import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; import { Language } from '../content/language.js'; +import { SubmissionRepository } from '../../data/assignments/submission-repository.js'; -@Entity() +@Entity({ repository: () => SubmissionRepository }) export class Submission { @PrimaryKey({ type: 'string' }) learningObjectHruid!: string; @@ -14,8 +15,8 @@ export class Submission { }) learningObjectLanguage!: Language; - @PrimaryKey({ type: 'string' }) - learningObjectVersion: string = '1'; + @PrimaryKey({ type: 'numeric' }) + learningObjectVersion: number = 1; @PrimaryKey({ type: 'integer' }) submissionNumber!: number; diff --git a/backend/src/entities/classes/class-join-request.entity.ts b/backend/src/entities/classes/class-join-request.entity.ts index 06e4095b..62ed37d0 100644 --- a/backend/src/entities/classes/class-join-request.entity.ts +++ b/backend/src/entities/classes/class-join-request.entity.ts @@ -1,8 +1,9 @@ import { Entity, Enum, ManyToOne } from '@mikro-orm/core'; import { Student } from '../users/student.entity.js'; import { Class } from './class.entity.js'; +import { ClassJoinRequestRepository } from '../../data/classes/class-join-request-repository.js'; -@Entity() +@Entity({ repository: () => ClassJoinRequestRepository }) export class ClassJoinRequest { @ManyToOne({ entity: () => Student, diff --git a/backend/src/entities/classes/class.entity.ts b/backend/src/entities/classes/class.entity.ts index 88d2660c..d4e44bf9 100644 --- a/backend/src/entities/classes/class.entity.ts +++ b/backend/src/entities/classes/class.entity.ts @@ -2,8 +2,9 @@ import { Collection, Entity, ManyToMany, PrimaryKey, Property } from '@mikro-orm import { v4 } from 'uuid'; import { Teacher } from '../users/teacher.entity.js'; import { Student } from '../users/student.entity.js'; +import { ClassRepository } from '../../data/classes/class-repository.js'; -@Entity() +@Entity({ repository: () => ClassRepository }) export class Class { @PrimaryKey() classId = v4(); diff --git a/backend/src/entities/classes/teacher-invitation.entity.ts b/backend/src/entities/classes/teacher-invitation.entity.ts index a09d71d0..eb57d98a 100644 --- a/backend/src/entities/classes/teacher-invitation.entity.ts +++ b/backend/src/entities/classes/teacher-invitation.entity.ts @@ -1,11 +1,15 @@ import { Entity, ManyToOne } from '@mikro-orm/core'; import { Teacher } from '../users/teacher.entity.js'; import { Class } from './class.entity.js'; +import { TeacherInvitationRepository } from '../../data/classes/teacher-invitation-repository.js'; /** * Invitation of a teacher into a class (in order to teach it). */ -@Entity() +@Entity({ repository: () => TeacherInvitationRepository }) +@Entity({ + repository: () => TeacherInvitationRepository, +}) export class TeacherInvitation { @ManyToOne({ entity: () => Teacher, diff --git a/backend/src/entities/content/attachment.entity.ts b/backend/src/entities/content/attachment.entity.ts index 9e4c6275..0c0f53c4 100644 --- a/backend/src/entities/content/attachment.entity.ts +++ b/backend/src/entities/content/attachment.entity.ts @@ -1,7 +1,8 @@ import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; import { LearningObject } from './learning-object.entity.js'; +import { AttachmentRepository } from '../../data/content/attachment-repository.js'; -@Entity() +@Entity({ repository: () => AttachmentRepository }) export class Attachment { @ManyToOne({ entity: () => LearningObject, @@ -9,8 +10,8 @@ export class Attachment { }) learningObject!: LearningObject; - @PrimaryKey({ type: 'integer' }) - sequenceNumber!: number; + @PrimaryKey({ type: 'string' }) + name!: string; @Property({ type: 'string' }) mimeType!: string; diff --git a/backend/src/entities/content/language.ts b/backend/src/entities/content/language.ts index b5d18c80..d7687331 100644 --- a/backend/src/entities/content/language.ts +++ b/backend/src/entities/content/language.ts @@ -1,6 +1,186 @@ export enum Language { + Afar = 'aa', + Abkhazian = 'ab', + Afrikaans = 'af', + Akan = 'ak', + Albanian = 'sq', + Amharic = 'am', + Arabic = 'ar', + Aragonese = 'an', + Armenian = 'hy', + Assamese = 'as', + Avaric = 'av', + Avestan = 'ae', + Aymara = 'ay', + Azerbaijani = 'az', + Bashkir = 'ba', + Bambara = 'bm', + Basque = 'eu', + Belarusian = 'be', + Bengali = 'bn', + Bihari = 'bh', + Bislama = 'bi', + Bosnian = 'bs', + Breton = 'br', + Bulgarian = 'bg', + Burmese = 'my', + Catalan = 'ca', + Chamorro = 'ch', + Chechen = 'ce', + Chinese = 'zh', + ChurchSlavic = 'cu', + Chuvash = 'cv', + Cornish = 'kw', + Corsican = 'co', + Cree = 'cr', + Czech = 'cs', + Danish = 'da', + Divehi = 'dv', Dutch = 'nl', - French = 'fr', + Dzongkha = 'dz', English = 'en', - Germany = 'de', + Esperanto = 'eo', + Estonian = 'et', + Ewe = 'ee', + Faroese = 'fo', + Fijian = 'fj', + Finnish = 'fi', + French = 'fr', + Frisian = 'fy', + Fulah = 'ff', + Georgian = 'ka', + German = 'de', + Gaelic = 'gd', + Irish = 'ga', + Galician = 'gl', + Manx = 'gv', + Greek = 'el', + Guarani = 'gn', + Gujarati = 'gu', + Haitian = 'ht', + Hausa = 'ha', + Hebrew = 'he', + Herero = 'hz', + Hindi = 'hi', + HiriMotu = 'ho', + Croatian = 'hr', + Hungarian = 'hu', + Igbo = 'ig', + Icelandic = 'is', + Ido = 'io', + SichuanYi = 'ii', + Inuktitut = 'iu', + Interlingue = 'ie', + Interlingua = 'ia', + Indonesian = 'id', + Inupiaq = 'ik', + Italian = 'it', + Javanese = 'jv', + Japanese = 'ja', + Kalaallisut = 'kl', + Kannada = 'kn', + Kashmiri = 'ks', + Kanuri = 'kr', + Kazakh = 'kk', + Khmer = 'km', + Kikuyu = 'ki', + Kinyarwanda = 'rw', + Kirghiz = 'ky', + Komi = 'kv', + Kongo = 'kg', + Korean = 'ko', + Kuanyama = 'kj', + Kurdish = 'ku', + Lao = 'lo', + Latin = 'la', + Latvian = 'lv', + Limburgan = 'li', + Lingala = 'ln', + Lithuanian = 'lt', + Luxembourgish = 'lb', + LubaKatanga = 'lu', + Ganda = 'lg', + Macedonian = 'mk', + Marshallese = 'mh', + Malayalam = 'ml', + Maori = 'mi', + Marathi = 'mr', + Malay = 'ms', + Malagasy = 'mg', + Maltese = 'mt', + Mongolian = 'mn', + Nauru = 'na', + Navajo = 'nv', + SouthNdebele = 'nr', + NorthNdebele = 'nd', + Ndonga = 'ng', + Nepali = 'ne', + NorwegianNynorsk = 'nn', + NorwegianBokmal = 'nb', + Norwegian = 'no', + Chichewa = 'ny', + Occitan = 'oc', + Ojibwa = 'oj', + Oriya = 'or', + Oromo = 'om', + Ossetian = 'os', + Punjabi = 'pa', + Persian = 'fa', + Pali = 'pi', + Polish = 'pl', + Portuguese = 'pt', + Pashto = 'ps', + Quechua = 'qu', + Romansh = 'rm', + Romanian = 'ro', + Rundi = 'rn', + Russian = 'ru', + Sango = 'sg', + Sanskrit = 'sa', + Sinhala = 'si', + Slovak = 'sk', + Slovenian = 'sl', + NorthernSami = 'se', + Samoan = 'sm', + Shona = 'sn', + Sindhi = 'sd', + Somali = 'so', + Sotho = 'st', + Spanish = 'es', + Sardinian = 'sc', + Serbian = 'sr', + Swati = 'ss', + Sundanese = 'su', + Swahili = 'sw', + Swedish = 'sv', + Tahitian = 'ty', + Tamil = 'ta', + Tatar = 'tt', + Telugu = 'te', + Tajik = 'tg', + Tagalog = 'tl', + Thai = 'th', + Tibetan = 'bo', + Tigrinya = 'ti', + Tonga = 'to', + Tswana = 'tn', + Tsonga = 'ts', + Turkmen = 'tk', + Turkish = 'tr', + Twi = 'tw', + Uighur = 'ug', + Ukrainian = 'uk', + Urdu = 'ur', + Uzbek = 'uz', + Venda = 've', + Vietnamese = 'vi', + Volapuk = 'vo', + Welsh = 'cy', + Walloon = 'wa', + Wolof = 'wo', + Xhosa = 'xh', + Yiddish = 'yi', + Yoruba = 'yo', + Zhuang = 'za', + Zulu = 'zu', } diff --git a/backend/src/entities/content/learning-object-identifier.ts b/backend/src/entities/content/learning-object-identifier.ts index 48d173c1..3c020bd7 100644 --- a/backend/src/entities/content/learning-object-identifier.ts +++ b/backend/src/entities/content/learning-object-identifier.ts @@ -4,6 +4,6 @@ export class LearningObjectIdentifier { constructor( public hruid: string, public language: Language, - public version: string + public version: number ) {} } diff --git a/backend/src/entities/content/learning-object.entity.ts b/backend/src/entities/content/learning-object.entity.ts index 9ff12ff0..9eda22ba 100644 --- a/backend/src/entities/content/learning-object.entity.ts +++ b/backend/src/entities/content/learning-object.entity.ts @@ -2,6 +2,9 @@ import { Embeddable, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, import { Language } from './language.js'; import { Attachment } from './attachment.entity.js'; import { Teacher } from '../users/teacher.entity.js'; +import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js'; +import { v4 } from 'uuid'; +import { LearningObjectRepository } from '../../data/content/learning-object-repository.js'; @Embeddable() export class EducationalGoal { @@ -21,7 +24,7 @@ export class ReturnValue { callbackSchema!: string; } -@Entity() +@Entity({ repository: () => LearningObjectRepository }) export class LearningObject { @PrimaryKey({ type: 'string' }) hruid!: string; @@ -32,8 +35,11 @@ export class LearningObject { }) language!: Language; - @PrimaryKey({ type: 'string' }) - version: string = '1'; + @PrimaryKey({ type: 'number' }) + version: number = 1; + + @Property({ type: 'uuid', unique: true }) + uuid = v4(); @ManyToMany({ entity: () => Teacher, @@ -47,19 +53,19 @@ export class LearningObject { description!: string; @Property({ type: 'string' }) - contentType!: string; + contentType!: DwengoContentType; @Property({ type: 'array' }) keywords: string[] = []; @Property({ type: 'array', nullable: true }) - targetAges?: number[]; + targetAges?: number[] = []; @Property({ type: 'bool' }) teacherExclusive: boolean = false; @Property({ type: 'array' }) - skosConcepts!: string[]; + skosConcepts: string[] = []; @Embedded({ entity: () => EducationalGoal, @@ -76,8 +82,8 @@ export class LearningObject { @Property({ type: 'smallint', nullable: true }) difficulty?: number; - @Property({ type: 'integer' }) - estimatedTime!: number; + @Property({ type: 'integer', nullable: true }) + estimatedTime?: number; @Embedded({ entity: () => ReturnValue, @@ -99,12 +105,3 @@ export class LearningObject { @Property({ type: 'blob' }) content!: Buffer; } - -export enum ContentType { - Markdown = 'text/markdown', - Image = 'image/image', - Mpeg = 'audio/mpeg', - Pdf = 'application/pdf', - Extern = 'extern', - Blockly = 'Blockly', -} diff --git a/backend/src/entities/content/learning-path-node.entity.ts b/backend/src/entities/content/learning-path-node.entity.ts new file mode 100644 index 00000000..03499270 --- /dev/null +++ b/backend/src/entities/content/learning-path-node.entity.ts @@ -0,0 +1,37 @@ +import { Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core'; +import { Language } from './language.js'; +import { LearningPath } from './learning-path.entity.js'; +import { LearningPathTransition } from './learning-path-transition.entity.js'; + +@Entity() +export class LearningPathNode { + @ManyToOne({ entity: () => LearningPath, primary: true }) + learningPath!: Rel; + + @PrimaryKey({ type: 'integer', autoincrement: true }) + nodeNumber!: number; + + @Property({ type: 'string' }) + learningObjectHruid!: string; + + @Enum({ items: () => Language }) + language!: Language; + + @Property({ type: 'number' }) + version!: number; + + @Property({ type: 'text', nullable: true }) + instruction?: string; + + @Property({ type: 'bool' }) + startNode!: boolean; + + @OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node' }) + transitions: LearningPathTransition[] = []; + + @Property({ length: 3 }) + createdAt: Date = new Date(); + + @Property({ length: 3, onUpdate: () => new Date() }) + updatedAt: Date = new Date(); +} diff --git a/backend/src/entities/content/learning-path-transition.entity.ts b/backend/src/entities/content/learning-path-transition.entity.ts new file mode 100644 index 00000000..7d6601a3 --- /dev/null +++ b/backend/src/entities/content/learning-path-transition.entity.ts @@ -0,0 +1,17 @@ +import { Entity, ManyToOne, PrimaryKey, Property, Rel } from '@mikro-orm/core'; +import { LearningPathNode } from './learning-path-node.entity.js'; + +@Entity() +export class LearningPathTransition { + @ManyToOne({ entity: () => LearningPathNode, primary: true }) + node!: Rel; + + @PrimaryKey({ type: 'numeric' }) + transitionNumber!: number; + + @Property({ type: 'string' }) + condition!: string; + + @ManyToOne({ entity: () => LearningPathNode }) + next!: Rel; +} diff --git a/backend/src/entities/content/learning-path.entity.ts b/backend/src/entities/content/learning-path.entity.ts index 8dead69d..888cc0cf 100644 --- a/backend/src/entities/content/learning-path.entity.ts +++ b/backend/src/entities/content/learning-path.entity.ts @@ -1,21 +1,18 @@ -import { Embeddable, Embedded, Entity, Enum, ManyToMany, OneToOne, PrimaryKey, Property } from '@mikro-orm/core'; +import { Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; import { Language } from './language.js'; import { Teacher } from '../users/teacher.entity.js'; +import { LearningPathRepository } from '../../data/content/learning-path-repository.js'; +import { LearningPathNode } from './learning-path-node.entity.js'; -@Entity() +@Entity({ repository: () => LearningPathRepository }) export class LearningPath { @PrimaryKey({ type: 'string' }) hruid!: string; - @Enum({ - items: () => Language, - primary: true, - }) + @Enum({ items: () => Language, primary: true }) language!: Language; - @ManyToMany({ - entity: () => Teacher, - }) + @ManyToMany({ entity: () => Teacher }) admins!: Teacher[]; @Property({ type: 'string' }) @@ -24,49 +21,9 @@ export class LearningPath { @Property({ type: 'text' }) description!: string; - @Property({ type: 'blob' }) - image!: string; + @Property({ type: 'blob', nullable: true }) + image: Buffer | null = null; - @Embedded({ - entity: () => LearningPathNode, - array: true, - }) + @OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' }) nodes: LearningPathNode[] = []; } - -@Embeddable() -export class LearningPathNode { - @Property({ type: 'string' }) - learningObjectHruid!: string; - - @Enum({ - items: () => Language, - }) - language!: Language; - - @Property({ type: 'string' }) - version!: string; - - @Property({ type: 'longtext' }) - instruction!: string; - - @Property({ type: 'bool' }) - startNode!: boolean; - - @Embedded({ - entity: () => LearningPathTransition, - array: true, - }) - transitions!: LearningPathTransition[]; -} - -@Embeddable() -export class LearningPathTransition { - @Property({ type: 'string' }) - condition!: string; - - @OneToOne({ - entity: () => LearningPathNode, - }) - next!: LearningPathNode; -} diff --git a/backend/src/entities/questions/answer.entity.ts b/backend/src/entities/questions/answer.entity.ts index 627a49d0..96bdc27d 100644 --- a/backend/src/entities/questions/answer.entity.ts +++ b/backend/src/entities/questions/answer.entity.ts @@ -1,8 +1,9 @@ import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; import { Question } from './question.entity.js'; import { Teacher } from '../users/teacher.entity.js'; +import { AnswerRepository } from '../../data/questions/answer-repository.js'; -@Entity() +@Entity({ repository: () => AnswerRepository }) export class Answer { @ManyToOne({ entity: () => Teacher, @@ -16,8 +17,8 @@ export class Answer { }) toQuestion!: Question; - @PrimaryKey({ type: 'integer' }) - sequenceNumber!: number; + @PrimaryKey({ type: 'integer', autoincrement: true }) + sequenceNumber?: number; @Property({ type: 'datetime' }) timestamp: Date = new Date(); diff --git a/backend/src/entities/questions/question.entity.ts b/backend/src/entities/questions/question.entity.ts index 854a4512..058ba6b3 100644 --- a/backend/src/entities/questions/question.entity.ts +++ b/backend/src/entities/questions/question.entity.ts @@ -1,8 +1,9 @@ import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; import { Language } from '../content/language.js'; import { Student } from '../users/student.entity.js'; +import { QuestionRepository } from '../../data/questions/question-repository.js'; -@Entity() +@Entity({ repository: () => QuestionRepository }) export class Question { @PrimaryKey({ type: 'string' }) learningObjectHruid!: string; @@ -13,11 +14,11 @@ export class Question { }) learningObjectLanguage!: Language; - @PrimaryKey({ type: 'string' }) - learningObjectVersion: string = '1'; + @PrimaryKey({ type: 'number' }) + learningObjectVersion: number = 1; - @PrimaryKey({ type: 'integer' }) - sequenceNumber!: number; + @PrimaryKey({ type: 'integer', autoincrement: true }) + sequenceNumber?: number; @ManyToOne({ entity: () => Student, diff --git a/backend/src/entities/users/teacher.entity.ts b/backend/src/entities/users/teacher.entity.ts index 2327527c..8e22d1de 100644 --- a/backend/src/entities/users/teacher.entity.ts +++ b/backend/src/entities/users/teacher.entity.ts @@ -1,9 +1,18 @@ import { Collection, Entity, ManyToMany } from '@mikro-orm/core'; import { User } from './user.entity.js'; import { Class } from '../classes/class.entity.js'; +import { TeacherRepository } from '../../data/users/teacher-repository.js'; -@Entity() +@Entity({ repository: () => TeacherRepository }) export class Teacher extends User { @ManyToMany(() => Class) classes!: Collection; + + constructor( + public username: string, + public firstName: string, + public lastName: string + ) { + super(); + } } diff --git a/backend/src/exceptions.ts b/backend/src/exceptions.ts index a76e2b72..e93a6c93 100644 --- a/backend/src/exceptions.ts +++ b/backend/src/exceptions.ts @@ -1,3 +1,17 @@ +/** + * Exception for HTTP 400 Bad Request + */ +export class BadRequestException extends Error { + public status = 400; + + constructor(error: string) { + super(error); + } +} + +/** + * Exception for HTTP 401 Unauthorized + */ export class UnauthorizedException extends Error { status = 401; constructor(message: string = 'Unauthorized') { @@ -5,9 +19,24 @@ export class UnauthorizedException extends Error { } } +/** + * Exception for HTTP 403 Forbidden + */ export class ForbiddenException extends Error { status = 403; + constructor(message: string = 'Forbidden') { super(message); } } + +/** + * Exception for HTTP 404 Not Found + */ +export class NotFoundException extends Error { + public status = 404; + + constructor(error: string) { + super(error); + } +} diff --git a/backend/src/interfaces/learningPath.ts b/backend/src/interfaces/learning-content.ts similarity index 81% rename from backend/src/interfaces/learningPath.ts rename to backend/src/interfaces/learning-content.ts index 1e2cc6ef..51474917 100644 --- a/backend/src/interfaces/learningPath.ts +++ b/backend/src/interfaces/learning-content.ts @@ -1,3 +1,5 @@ +import { Language } from '../entities/content/language'; + export interface Transition { default: boolean; _id: string; @@ -9,15 +11,22 @@ export interface Transition { }; } +export interface LearningObjectIdentifier { + hruid: string; + language: Language; + version?: number; +} + export interface LearningObjectNode { _id: string; learningobject_hruid: string; version: number; - language: string; + language: Language; start_node?: boolean; transitions: Transition[]; created_at: string; updatedAt: string; + done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized. } export interface LearningPath { @@ -37,6 +46,11 @@ export interface LearningPath { __order: number; } +export interface LearningPathIdentifier { + hruid: string; + language: Language; +} + export interface EducationalGoal { source: string; id: string; @@ -52,7 +66,7 @@ export interface LearningObjectMetadata { uuid: string; hruid: string; version: number; - language: string; + language: Language; title: string; description: string; difficulty: number; @@ -75,9 +89,9 @@ export interface FilteredLearningObject { version: number; title: string; htmlUrl: string; - language: string; + language: Language; difficulty: number; - estimatedTime: number; + estimatedTime?: number; available: boolean; teacherExclusive: boolean; educationalGoals: EducationalGoal[]; diff --git a/backend/src/mikro-orm.config.ts b/backend/src/mikro-orm.config.ts index 56221473..c9cf6ed9 100644 --- a/backend/src/mikro-orm.config.ts +++ b/backend/src/mikro-orm.config.ts @@ -24,6 +24,7 @@ import { LearningPath } from './entities/content/learning-path.entity.js'; import { Answer } from './entities/questions/answer.entity.js'; import { Question } from './entities/questions/question.entity.js'; +import { SqliteAutoincrementSubscriber } from './sqlite-autoincrement-workaround.js'; const entities = [ User, @@ -47,6 +48,7 @@ function config(testingMode: boolean = false): Options { return { driver: SqliteDriver, dbName: getEnvVar(EnvVars.DbName), + subscribers: [new SqliteAutoincrementSubscriber()], entities: entities, // EntitiesTs: entitiesTs, diff --git a/backend/src/routes/learningObjects.ts b/backend/src/routes/learning-objects.ts similarity index 50% rename from backend/src/routes/learningObjects.ts rename to backend/src/routes/learning-objects.ts index 681baa2f..b731fe69 100644 --- a/backend/src/routes/learningObjects.ts +++ b/backend/src/routes/learning-objects.ts @@ -1,5 +1,5 @@ import express from 'express'; -import { getAllLearningObjects, getLearningObject } from '../controllers/learningObjects.js'; +import { getAllLearningObjects, getAttachment, getLearningObject, getLearningObjectHTML } from '../controllers/learning-objects.js'; const router = express.Router(); @@ -21,4 +21,16 @@ router.get('/', getAllLearningObjects); // Example: http://localhost:3000/learningObject/un_ai7 router.get('/:hruid', getLearningObject); +// Parameter: hruid of learning object +// Query: language, version (optional) +// Route to fetch the HTML rendering of one learning object based on its hruid. +// Example: http://localhost:3000/learningObject/un_ai7/html +router.get('/:hruid/html', getLearningObjectHTML); + +// Parameter: hruid of learning object, name of attachment. +// Query: language, version (optional). +// Route to get the raw data of the attachment for one learning object based on its hruid. +// Example: http://localhost:3000/learningObject/u_test/attachment/testimage.png +router.get('/:hruid/html/:attachmentName', getAttachment); + export default router; diff --git a/backend/src/routes/learningPaths.ts b/backend/src/routes/learning-paths.ts similarity index 91% rename from backend/src/routes/learningPaths.ts rename to backend/src/routes/learning-paths.ts index ce580745..efe17312 100644 --- a/backend/src/routes/learningPaths.ts +++ b/backend/src/routes/learning-paths.ts @@ -1,5 +1,5 @@ import express from 'express'; -import { getLearningPaths } from '../controllers/learningPaths.js'; +import { getLearningPaths } from '../controllers/learning-paths.js'; const router = express.Router(); diff --git a/backend/src/services/learning-objects/attachment-service.ts b/backend/src/services/learning-objects/attachment-service.ts new file mode 100644 index 00000000..aacc7187 --- /dev/null +++ b/backend/src/services/learning-objects/attachment-service.ts @@ -0,0 +1,23 @@ +import { getAttachmentRepository } from '../../data/repositories.js'; +import { Attachment } from '../../entities/content/attachment.entity.js'; +import { LearningObjectIdentifier } from '../../interfaces/learning-content.js'; + +const attachmentService = { + getAttachment(learningObjectId: LearningObjectIdentifier, attachmentName: string): Promise { + const attachmentRepo = getAttachmentRepository(); + + if (learningObjectId.version) { + return attachmentRepo.findByLearningObjectIdAndName( + { + hruid: learningObjectId.hruid, + language: learningObjectId.language, + version: learningObjectId.version, + }, + attachmentName + ); + } + return attachmentRepo.findByMostRecentVersionOfLearningObjectAndName(learningObjectId.hruid, learningObjectId.language, attachmentName); + }, +}; + +export default attachmentService; diff --git a/backend/src/services/learning-objects/database-learning-object-provider.ts b/backend/src/services/learning-objects/database-learning-object-provider.ts new file mode 100644 index 00000000..bab0b9b1 --- /dev/null +++ b/backend/src/services/learning-objects/database-learning-object-provider.ts @@ -0,0 +1,115 @@ +import { LearningObjectProvider } from './learning-object-provider.js'; +import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js'; +import { getLearningObjectRepository, getLearningPathRepository } from '../../data/repositories.js'; +import { Language } from '../../entities/content/language.js'; +import { LearningObject } from '../../entities/content/learning-object.entity.js'; +import { getUrlStringForLearningObject } from '../../util/links.js'; +import processingService from './processing/processing-service.js'; +import { NotFoundError } from '@mikro-orm/core'; +import learningObjectService from './learning-object-service.js'; +import { getLogger, Logger } from '../../logging/initalize.js'; + +const logger: Logger = getLogger(); + +function convertLearningObject(learningObject: LearningObject | null): FilteredLearningObject | null { + if (!learningObject) { + return null; + } + return { + key: learningObject.hruid, + _id: learningObject.uuid, // For backwards compatibility with the original Dwengo API, we also populate the _id field. + uuid: learningObject.uuid, + language: learningObject.language, + version: learningObject.version, + title: learningObject.title, + description: learningObject.description, + htmlUrl: getUrlStringForLearningObject(learningObject), + available: learningObject.available, + contentType: learningObject.contentType, + contentLocation: learningObject.contentLocation, + difficulty: learningObject.difficulty || 1, + estimatedTime: learningObject.estimatedTime, + keywords: learningObject.keywords, + educationalGoals: learningObject.educationalGoals, + returnValue: { + callback_url: learningObject.returnValue.callbackUrl, + callback_schema: JSON.parse(learningObject.returnValue.callbackSchema), + }, + skosConcepts: learningObject.skosConcepts, + targetAges: learningObject.targetAges || [], + teacherExclusive: learningObject.teacherExclusive, + }; +} + +function findLearningObjectEntityById(id: LearningObjectIdentifier): Promise { + const learningObjectRepo = getLearningObjectRepository(); + + return learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language as Language); +} + +/** + * Service providing access to data about learning objects from the database + */ +const databaseLearningObjectProvider: LearningObjectProvider = { + /** + * Fetches a single learning object by its HRUID + */ + async getLearningObjectById(id: LearningObjectIdentifier): Promise { + const learningObject = await findLearningObjectEntityById(id); + return convertLearningObject(learningObject); + }, + + /** + * Obtain a HTML-rendering of the learning object with the given identifier (as a string). + */ + async getLearningObjectHTML(id: LearningObjectIdentifier): Promise { + const learningObjectRepo = getLearningObjectRepository(); + + const learningObject = await learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language as Language); + if (!learningObject) { + return null; + } + return await processingService.render(learningObject, (id) => findLearningObjectEntityById(id)); + }, + + /** + * Fetch the HRUIDs of all learning objects on this path. + */ + async getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise { + const learningPathRepo = getLearningPathRepository(); + + const learningPath = await learningPathRepo.findByHruidAndLanguage(id.hruid, id.language); + if (!learningPath) { + throw new NotFoundError('The learning path with the given ID could not be found.'); + } + return learningPath.nodes.map((it) => it.learningObjectHruid); // TODO: Determine this based on the submissions of the user. + }, + + /** + * Fetch the full metadata of all learning objects on this path. + */ + async getLearningObjectsFromPath(id: LearningPathIdentifier): Promise { + const learningPathRepo = getLearningPathRepository(); + + const learningPath = await learningPathRepo.findByHruidAndLanguage(id.hruid, id.language); + if (!learningPath) { + throw new NotFoundError('The learning path with the given ID could not be found.'); + } + const learningObjects = await Promise.all( + learningPath.nodes.map((it) => { + const learningObject = learningObjectService.getLearningObjectById({ + hruid: it.learningObjectHruid, + language: it.language, + version: it.version, + }); + if (learningObject === null) { + logger.warn(`WARN: Learning object corresponding with node ${it} not found!`); + } + return learningObject; + }) + ); + return learningObjects.filter((it) => it !== null); + }, +}; + +export default databaseLearningObjectProvider; diff --git a/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts b/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts new file mode 100644 index 00000000..37e68c07 --- /dev/null +++ b/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts @@ -0,0 +1,138 @@ +import { DWENGO_API_BASE } from '../../config.js'; +import { fetchWithLogging } from '../../util/apiHelper.js'; +import { + FilteredLearningObject, + LearningObjectIdentifier, + LearningObjectMetadata, + LearningObjectNode, + LearningPathIdentifier, + LearningPathResponse, +} from '../../interfaces/learning-content.js'; +import dwengoApiLearningPathProvider from '../learning-paths/dwengo-api-learning-path-provider.js'; +import { LearningObjectProvider } from './learning-object-provider.js'; +import { getLogger, Logger } from '../../logging/initalize.js'; + +const logger: Logger = getLogger(); + +/** + * Helper function to convert the learning object metadata retrieved from the API to a FilteredLearningObject which + * our API should return. + * @param data + */ +function filterData(data: LearningObjectMetadata): FilteredLearningObject { + return { + key: data.hruid, // Hruid learningObject (not path) + _id: data._id, + uuid: data.uuid, + version: data.version, + title: data.title, + htmlUrl: `/learningObject/${data.hruid}/html?language=${data.language}&version=${data.version}`, // Url to fetch html content + language: data.language, + difficulty: data.difficulty, + estimatedTime: data.estimated_time, + available: data.available, + teacherExclusive: data.teacher_exclusive, + educationalGoals: data.educational_goals, // List with learningObjects + keywords: data.keywords, // For search + description: data.description, // For search (not an actual description) + targetAges: data.target_ages, + contentType: data.content_type, // Markdown, image, audio, etc. + contentLocation: data.content_location, // If content type extern + skosConcepts: data.skos_concepts, + returnValue: data.return_value, // Callback response information + }; +} + +/** + * Generic helper function to fetch all learning objects from a given path (full data or just HRUIDs) + */ +async function fetchLearningObjects(learningPathId: LearningPathIdentifier, full: boolean): Promise { + try { + const learningPathResponse: LearningPathResponse = await dwengoApiLearningPathProvider.fetchLearningPaths( + [learningPathId.hruid], + learningPathId.language, + `Learning path for HRUID "${learningPathId.hruid}"` + ); + + if (!learningPathResponse.success || !learningPathResponse.data?.length) { + logger.warn(`⚠️ WARNING: Learning path "${learningPathId.hruid}" exists but contains no learning objects.`); + return []; + } + + const nodes: LearningObjectNode[] = learningPathResponse.data[0].nodes; + + if (!full) { + return nodes.map((node) => node.learningobject_hruid); + } + + const objects = await Promise.all( + nodes.map(async (node) => + dwengoApiLearningObjectProvider.getLearningObjectById({ + hruid: node.learningobject_hruid, + language: learningPathId.language, + }) + ) + ); + return objects.filter((obj): obj is FilteredLearningObject => obj !== null); + } catch (error) { + logger.error('❌ Error fetching learning objects:', error); + return []; + } +} + +const dwengoApiLearningObjectProvider: LearningObjectProvider = { + /** + * Fetches a single learning object by its HRUID + */ + async getLearningObjectById(id: LearningObjectIdentifier): Promise { + const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata`; + const metadata = await fetchWithLogging( + metadataUrl, + `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, + { + params: id, + } + ); + + if (!metadata || typeof metadata !== 'object') { + logger.warn(`⚠️ WARNING: Learning object "${id.hruid}" not found.`); + return null; + } + + return filterData(metadata); + }, + + /** + * Fetch full learning object data (metadata) + */ + async getLearningObjectsFromPath(id: LearningPathIdentifier): Promise { + return (await fetchLearningObjects(id, true)) as FilteredLearningObject[]; + }, + + /** + * Fetch only learning object HRUIDs + */ + async getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise { + return (await fetchLearningObjects(id, false)) as string[]; + }, + + /** + * Obtain a HTML-rendering of the learning object with the given identifier (as a string). For learning objects + * from the Dwengo API, this means passing through the HTML rendering from there. + */ + async getLearningObjectHTML(id: LearningObjectIdentifier): Promise { + const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw`; + const html = await fetchWithLogging(htmlUrl, `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, { + params: id, + }); + + if (!html) { + logger.warn(`⚠️ WARNING: Learning object "${id.hruid}" not found.`); + return null; + } + + return html; + }, +}; + +export default dwengoApiLearningObjectProvider; diff --git a/backend/src/services/learning-objects/learning-object-provider.ts b/backend/src/services/learning-objects/learning-object-provider.ts new file mode 100644 index 00000000..81b4d228 --- /dev/null +++ b/backend/src/services/learning-objects/learning-object-provider.ts @@ -0,0 +1,23 @@ +import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js'; + +export interface LearningObjectProvider { + /** + * Fetches a single learning object by its HRUID + */ + getLearningObjectById(id: LearningObjectIdentifier): Promise; + + /** + * Fetch full learning object data (metadata) + */ + getLearningObjectsFromPath(id: LearningPathIdentifier): Promise; + + /** + * Fetch only learning object HRUIDs + */ + getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise; + + /** + * Obtain a HTML-rendering of the learning object with the given identifier (as a string). + */ + getLearningObjectHTML(id: LearningObjectIdentifier): Promise; +} diff --git a/backend/src/services/learning-objects/learning-object-service.ts b/backend/src/services/learning-objects/learning-object-service.ts new file mode 100644 index 00000000..8289660b --- /dev/null +++ b/backend/src/services/learning-objects/learning-object-service.ts @@ -0,0 +1,47 @@ +import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js'; +import dwengoApiLearningObjectProvider from './dwengo-api-learning-object-provider.js'; +import { LearningObjectProvider } from './learning-object-provider.js'; +import { EnvVars, getEnvVar } from '../../util/envvars.js'; +import databaseLearningObjectProvider from './database-learning-object-provider.js'; + +function getProvider(id: LearningObjectIdentifier): LearningObjectProvider { + if (id.hruid.startsWith(getEnvVar(EnvVars.UserContentPrefix))) { + return databaseLearningObjectProvider; + } + return dwengoApiLearningObjectProvider; +} + +/** + * Service providing access to data about learning objects from the appropriate data source (database or Dwengo-api) + */ +const learningObjectService = { + /** + * Fetches a single learning object by its HRUID + */ + getLearningObjectById(id: LearningObjectIdentifier): Promise { + return getProvider(id).getLearningObjectById(id); + }, + + /** + * Fetch full learning object data (metadata) + */ + getLearningObjectsFromPath(id: LearningPathIdentifier): Promise { + return getProvider(id).getLearningObjectsFromPath(id); + }, + + /** + * Fetch only learning object HRUIDs + */ + getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise { + return getProvider(id).getLearningObjectIdsFromPath(id); + }, + + /** + * Obtain a HTML-rendering of the learning object with the given identifier (as a string). + */ + getLearningObjectHTML(id: LearningObjectIdentifier): Promise { + return getProvider(id).getLearningObjectHTML(id); + }, +}; + +export default learningObjectService; diff --git a/backend/src/services/learning-objects/processing/audio/audio-processor.ts b/backend/src/services/learning-objects/processing/audio/audio-processor.ts new file mode 100644 index 00000000..592669d5 --- /dev/null +++ b/backend/src/services/learning-objects/processing/audio/audio-processor.ts @@ -0,0 +1,25 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/audio/audio_processor.js + * + * WARNING: The support for audio learning objects is currently still experimental. + */ + +import DOMPurify from 'isomorphic-dompurify'; +import { type } from 'node:os'; +import { DwengoContentType } from '../content-type.js'; +import { StringProcessor } from '../string-processor.js'; + +class AudioProcessor extends StringProcessor { + constructor() { + super(DwengoContentType.AUDIO_MPEG); + } + + protected renderFn(audioUrl: string): string { + return DOMPurify.sanitize(``); + } +} + +export default AudioProcessor; diff --git a/backend/src/services/learning-objects/processing/content-type.ts b/backend/src/services/learning-objects/processing/content-type.ts new file mode 100644 index 00000000..2ea44246 --- /dev/null +++ b/backend/src/services/learning-objects/processing/content-type.ts @@ -0,0 +1,18 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/content_type.js + */ + +enum DwengoContentType { + TEXT_PLAIN = 'text/plain', + TEXT_MARKDOWN = 'text/markdown', + IMAGE_BLOCK = 'image/image-block', + IMAGE_INLINE = 'image/image', + AUDIO_MPEG = 'audio/mpeg', + APPLICATION_PDF = 'application/pdf', + EXTERN = 'extern', + BLOCKLY = 'blockly', + GIFT = 'text/gift', + CT_SCHEMA = 'text/ct-schema', +} + +export { DwengoContentType }; diff --git a/backend/src/services/learning-objects/processing/extern/extern-processor.ts b/backend/src/services/learning-objects/processing/extern/extern-processor.ts new file mode 100644 index 00000000..453e998b --- /dev/null +++ b/backend/src/services/learning-objects/processing/extern/extern-processor.ts @@ -0,0 +1,40 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/extern/extern_processor.js + * + * WARNING: The support for external content is currently still experimental. + */ + +import DOMPurify from 'isomorphic-dompurify'; +import { ProcessingError } from '../processing-error.js'; +import { isValidHttpUrl } from '../../../../util/links.js'; +import { DwengoContentType } from '../content-type.js'; +import { StringProcessor } from '../string-processor.js'; + +class ExternProcessor extends StringProcessor { + constructor() { + super(DwengoContentType.EXTERN); + } + + override renderFn(externURL: string) { + if (!isValidHttpUrl(externURL)) { + throw new ProcessingError('The url is not valid: ' + externURL); + } + + // If a seperate youtube-processor would be added, this code would need to move to that processor + // Converts youtube urls to youtube-embed urls + const match = /(.*youtube.com\/)watch\?v=(.*)/.exec(externURL); + if (match) { + externURL = match[1] + 'embed/' + match[2]; + } + + return DOMPurify.sanitize( + ` +
+ +
`, + { ADD_TAGS: ['iframe'], ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling'] } + ); + } +} + +export default ExternProcessor; diff --git a/backend/src/services/learning-objects/processing/gift/gift-processor.ts b/backend/src/services/learning-objects/processing/gift/gift-processor.ts new file mode 100644 index 00000000..5396236a --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/gift-processor.ts @@ -0,0 +1,61 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/gift/gift_processor.js + */ + +import DOMPurify from 'isomorphic-dompurify'; +import { GIFTQuestion, parse } from 'gift-pegjs'; +import { DwengoContentType } from '../content-type.js'; +import { GIFTQuestionRenderer } from './question-renderers/gift-question-renderer.js'; +import { MultipleChoiceQuestionRenderer } from './question-renderers/multiple-choice-question-renderer.js'; +import { CategoryQuestionRenderer } from './question-renderers/category-question-renderer.js'; +import { DescriptionQuestionRenderer } from './question-renderers/description-question-renderer.js'; +import { EssayQuestionRenderer } from './question-renderers/essay-question-renderer.js'; +import { MatchingQuestionRenderer } from './question-renderers/matching-question-renderer.js'; +import { NumericalQuestionRenderer } from './question-renderers/numerical-question-renderer.js'; +import { ShortQuestionRenderer } from './question-renderers/short-question-renderer.js'; +import { TrueFalseQuestionRenderer } from './question-renderers/true-false-question-renderer.js'; +import { StringProcessor } from '../string-processor.js'; + +class GiftProcessor extends StringProcessor { + private renderers: RendererMap = { + Category: new CategoryQuestionRenderer(), + Description: new DescriptionQuestionRenderer(), + Essay: new EssayQuestionRenderer(), + Matching: new MatchingQuestionRenderer(), + Numerical: new NumericalQuestionRenderer(), + Short: new ShortQuestionRenderer(), + TF: new TrueFalseQuestionRenderer(), + MC: new MultipleChoiceQuestionRenderer(), + }; + + constructor() { + super(DwengoContentType.GIFT); + } + + override renderFn(giftString: string) { + const quizQuestions: GIFTQuestion[] = parse(giftString); + + let html = "
\n"; + let i = 1; + for (const question of quizQuestions) { + html += `
\n`; + html += ' ' + this.renderQuestion(question, i).replaceAll(/\n(.+)/g, '\n $1'); // Replace for indentation. + html += `
\n`; + i++; + } + html += '
\n'; + + return DOMPurify.sanitize(html); + } + + private renderQuestion(question: T, questionNumber: number): string { + const renderer = this.renderers[question.type] as GIFTQuestionRenderer; + return renderer.render(question, questionNumber); + } +} + +type RendererMap = { + [K in GIFTQuestion['type']]: GIFTQuestionRenderer>; +}; + +export default GiftProcessor; diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/category-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/category-question-renderer.ts new file mode 100644 index 00000000..507e5ada --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/category-question-renderer.ts @@ -0,0 +1,9 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { Category } from 'gift-pegjs'; +import { ProcessingError } from '../../processing-error.js'; + +export class CategoryQuestionRenderer extends GIFTQuestionRenderer { + render(question: Category, questionNumber: number): string { + throw new ProcessingError("The question type 'Category' is not supported yet!"); + } +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/description-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/description-question-renderer.ts new file mode 100644 index 00000000..0238d76a --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/description-question-renderer.ts @@ -0,0 +1,9 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { Description } from 'gift-pegjs'; +import { ProcessingError } from '../../processing-error.js'; + +export class DescriptionQuestionRenderer extends GIFTQuestionRenderer { + render(question: Description, questionNumber: number): string { + throw new ProcessingError("The question type 'Description' is not supported yet!"); + } +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/essay-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/essay-question-renderer.ts new file mode 100644 index 00000000..987fbcaf --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/essay-question-renderer.ts @@ -0,0 +1,16 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { Essay } from 'gift-pegjs'; + +export class EssayQuestionRenderer extends GIFTQuestionRenderer { + render(question: Essay, questionNumber: number): string { + let renderedHtml = ''; + if (question.title) { + renderedHtml += `

${question.title}

\n`; + } + if (question.stem) { + renderedHtml += `

${question.stem.text}

\n`; + } + renderedHtml += `\n`; + return renderedHtml; + } +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/gift-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/gift-question-renderer.ts new file mode 100644 index 00000000..41ab5ba2 --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/gift-question-renderer.ts @@ -0,0 +1,14 @@ +import { GIFTQuestion } from 'gift-pegjs'; + +/** + * Subclasses of this class are renderers which can render a specific type of GIFT questions to HTML. + */ +export abstract class GIFTQuestionRenderer { + /** + * Render the given question to HTML. + * @param question The question. + * @param questionNumber The index number of the question. + * @returns The question rendered as HTML. + */ + abstract render(question: T, questionNumber: number): string; +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/matching-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/matching-question-renderer.ts new file mode 100644 index 00000000..bb6e9737 --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/matching-question-renderer.ts @@ -0,0 +1,9 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { Matching } from 'gift-pegjs'; +import { ProcessingError } from '../../processing-error.js'; + +export class MatchingQuestionRenderer extends GIFTQuestionRenderer { + render(question: Matching, questionNumber: number): string { + throw new ProcessingError("The question type 'Matching' is not supported yet!"); + } +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/multiple-choice-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/multiple-choice-question-renderer.ts new file mode 100644 index 00000000..39846c51 --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/multiple-choice-question-renderer.ts @@ -0,0 +1,23 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { MultipleChoice } from 'gift-pegjs'; + +export class MultipleChoiceQuestionRenderer extends GIFTQuestionRenderer { + render(question: MultipleChoice, questionNumber: number): string { + let renderedHtml = ''; + if (question.title) { + renderedHtml += `

${question.title}

\n`; + } + if (question.stem) { + renderedHtml += `

${question.stem.text}

\n`; + } + let i = 0; + for (const choice of question.choices) { + renderedHtml += `
\n`; + renderedHtml += ` \n`; + renderedHtml += ` \n`; + renderedHtml += `
\n`; + i++; + } + return renderedHtml; + } +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/numerical-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/numerical-question-renderer.ts new file mode 100644 index 00000000..32fdb06e --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/numerical-question-renderer.ts @@ -0,0 +1,9 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { Numerical } from 'gift-pegjs'; +import { ProcessingError } from '../../processing-error.js'; + +export class NumericalQuestionRenderer extends GIFTQuestionRenderer { + render(question: Numerical, questionNumber: number): string { + throw new ProcessingError("The question type 'Numerical' is not supported yet!"); + } +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/short-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/short-question-renderer.ts new file mode 100644 index 00000000..5a63531f --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/short-question-renderer.ts @@ -0,0 +1,9 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { ShortAnswer } from 'gift-pegjs'; +import { ProcessingError } from '../../processing-error.js'; + +export class ShortQuestionRenderer extends GIFTQuestionRenderer { + render(question: ShortAnswer, questionNumber: number): string { + throw new ProcessingError("The question type 'ShortAnswer' is not supported yet!"); + } +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/true-false-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/true-false-question-renderer.ts new file mode 100644 index 00000000..98148130 --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/true-false-question-renderer.ts @@ -0,0 +1,9 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { TrueFalse } from 'gift-pegjs'; +import { ProcessingError } from '../../processing-error.js'; + +export class TrueFalseQuestionRenderer extends GIFTQuestionRenderer { + render(question: TrueFalse, questionNumber: number): string { + throw new ProcessingError("The question type 'TrueFalse' is not supported yet!"); + } +} diff --git a/backend/src/services/learning-objects/processing/image/block-image-processor.ts b/backend/src/services/learning-objects/processing/image/block-image-processor.ts new file mode 100644 index 00000000..f4f8a773 --- /dev/null +++ b/backend/src/services/learning-objects/processing/image/block-image-processor.ts @@ -0,0 +1,19 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/image/block_image_processor.js + */ + +import InlineImageProcessor from './inline-image-processor.js'; +import DOMPurify from 'isomorphic-dompurify'; + +class BlockImageProcessor extends InlineImageProcessor { + constructor() { + super(); + } + + override renderFn(imageUrl: string) { + const inlineHtml = super.render(imageUrl); + return DOMPurify.sanitize(`
${inlineHtml}
`); + } +} + +export default BlockImageProcessor; diff --git a/backend/src/services/learning-objects/processing/image/inline-image-processor.ts b/backend/src/services/learning-objects/processing/image/inline-image-processor.ts new file mode 100644 index 00000000..478ce326 --- /dev/null +++ b/backend/src/services/learning-objects/processing/image/inline-image-processor.ts @@ -0,0 +1,24 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/image/inline_image_processor.js + */ + +import DOMPurify from 'isomorphic-dompurify'; +import { DwengoContentType } from '../content-type.js'; +import { ProcessingError } from '../processing-error.js'; +import { isValidHttpUrl } from '../../../../util/links.js'; +import { StringProcessor } from '../string-processor.js'; + +class InlineImageProcessor extends StringProcessor { + constructor(contentType: DwengoContentType = DwengoContentType.IMAGE_INLINE) { + super(contentType); + } + + override renderFn(imageUrl: string) { + if (!isValidHttpUrl(imageUrl)) { + throw new ProcessingError(`Image URL is invalid: ${imageUrl}`); + } + return DOMPurify.sanitize(``); + } +} + +export default InlineImageProcessor; diff --git a/backend/src/services/learning-objects/processing/markdown/dwengo-marked-renderer.ts b/backend/src/services/learning-objects/processing/markdown/dwengo-marked-renderer.ts new file mode 100644 index 00000000..d1c797be --- /dev/null +++ b/backend/src/services/learning-objects/processing/markdown/dwengo-marked-renderer.ts @@ -0,0 +1,109 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/markdown/learing_object_markdown_renderer.js [sic!] + */ +import PdfProcessor from '../pdf/pdf-processor.js'; +import AudioProcessor from '../audio/audio-processor.js'; +import ExternProcessor from '../extern/extern-processor.js'; +import InlineImageProcessor from '../image/inline-image-processor.js'; +import * as marked from 'marked'; +import { getUrlStringForLearningObjectHTML, isValidHttpUrl } from '../../../../util/links.js'; +import { ProcessingError } from '../processing-error.js'; +import { LearningObjectIdentifier } from '../../../../interfaces/learning-content.js'; +import { Language } from '../../../../entities/content/language.js'; + +import Image = marked.Tokens.Image; +import Heading = marked.Tokens.Heading; +import Link = marked.Tokens.Link; +import RendererObject = marked.RendererObject; + +const prefixes = { + learningObject: '@learning-object', + pdf: '@pdf', + audio: '@audio', + extern: '@extern', + video: '@youtube', + notebook: '@notebook', + blockly: '@blockly', +}; + +function extractLearningObjectIdFromHref(href: string): LearningObjectIdentifier { + const [hruid, language, version] = href.split(/\/(.+)/, 2)[1].split('/'); + return { + hruid, + language: language as Language, + version: parseInt(version), + }; +} + +/** + * An extension for the renderer of the Marked Markdown renderer which adds support for + * - a custom heading, + * - links to other learning objects, + * - embeddings of other learning objects. + */ +const dwengoMarkedRenderer: RendererObject = { + heading(heading: Heading): string { + const text = heading.text; + const level = heading.depth; + const escapedText = text.toLowerCase().replace(/[^\w]+/g, '-'); + + return ( + `\n` + + ` \n` + + ` \n` + + ` \n` + + ` ${text}\n` + + `\n` + ); + }, + + // When the syntax for a link is used => [text](href "title") + // Render a custom link when the prefix for a learning object is used. + link(link: Link): string { + const href = link.href; + const title = link.title || ''; + const text = marked.parseInline(link.text); // There could for example be an image in the link. + + if (href.startsWith(prefixes.learningObject)) { + // Link to learning-object + const learningObjectId = extractLearningObjectIdFromHref(href); + return `${text}`; + } + // Any other link + if (!isValidHttpUrl(href)) { + throw new ProcessingError('Link is not a valid HTTP URL!'); + } + // + return `${text}`; + }, + + // When the syntax for an image is used => ![text](href "title") + // Render a learning object, pdf, audio or video if a prefix is used. + image(img: Image): string { + const href = img.href; + if (href.startsWith(prefixes.learningObject)) { + // Embedded learning-object + const learningObjectId = extractLearningObjectIdFromHref(href); + return ` + + `; // Placeholder for the learning object since we cannot fetch its HTML here (this has to be a sync function!) + } else if (href.startsWith(prefixes.pdf)) { + // Embedded pdf + const proc = new PdfProcessor(); + return proc.render(href.split(/\/(.+)/, 2)[1]); + } else if (href.startsWith(prefixes.audio)) { + // Embedded audio + const proc = new AudioProcessor(); + return proc.render(href.split(/\/(.+)/, 2)[1]); + } else if (href.startsWith(prefixes.extern) || href.startsWith(prefixes.video) || href.startsWith(prefixes.notebook)) { + // Embedded youtube video or notebook (or other extern content) + const proc = new ExternProcessor(); + return proc.render(href.split(/\/(.+)/, 2)[1]); + } + // Embedded image + const proc = new InlineImageProcessor(); + return proc.render(href); + }, +}; + +export default dwengoMarkedRenderer; diff --git a/backend/src/services/learning-objects/processing/markdown/markdown-processor.ts b/backend/src/services/learning-objects/processing/markdown/markdown-processor.ts new file mode 100644 index 00000000..1de6b3d5 --- /dev/null +++ b/backend/src/services/learning-objects/processing/markdown/markdown-processor.ts @@ -0,0 +1,39 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/markdown/markdown_processor.js + */ + +import { marked } from 'marked'; +import InlineImageProcessor from '../image/inline-image-processor.js'; +import { DwengoContentType } from '../content-type.js'; +import dwengoMarkedRenderer from './dwengo-marked-renderer.js'; +import { StringProcessor } from '../string-processor.js'; +import { ProcessingError } from '../processing-error.js'; + +class MarkdownProcessor extends StringProcessor { + constructor() { + super(DwengoContentType.TEXT_MARKDOWN); + } + + override renderFn(mdText: string) { + let html = ''; + try { + marked.use({ renderer: dwengoMarkedRenderer }); + html = marked(mdText, { async: false }); + html = this.replaceLinks(html); // Replace html image links path + } catch (e: any) { + throw new ProcessingError(e.message); + } + return html; + } + + replaceLinks(html: string) { + const proc = new InlineImageProcessor(); + html = html.replace( + //g, + (match: string, src: string, alt: string, altText: string, title: string, titleText: string) => proc.render(src) + ); + return html; + } +} + +export { MarkdownProcessor }; diff --git a/backend/src/services/learning-objects/processing/pdf/pdf-processor.ts b/backend/src/services/learning-objects/processing/pdf/pdf-processor.ts new file mode 100644 index 00000000..26cb4d94 --- /dev/null +++ b/backend/src/services/learning-objects/processing/pdf/pdf-processor.ts @@ -0,0 +1,32 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/pdf/pdf_processor.js + * + * WARNING: The support for PDF learning objects is currently still experimental. + */ + +import DOMPurify from 'isomorphic-dompurify'; +import { DwengoContentType } from '../content-type.js'; +import { isValidHttpUrl } from '../../../../util/links.js'; +import { ProcessingError } from '../processing-error.js'; +import { StringProcessor } from '../string-processor.js'; + +class PdfProcessor extends StringProcessor { + constructor() { + super(DwengoContentType.APPLICATION_PDF); + } + + override renderFn(pdfUrl: string) { + if (!isValidHttpUrl(pdfUrl)) { + throw new ProcessingError(`PDF URL is invalid: ${pdfUrl}`); + } + + return DOMPurify.sanitize( + ` + + `, + { ADD_TAGS: ['embed'] } + ); + } +} + +export default PdfProcessor; diff --git a/backend/src/services/learning-objects/processing/processing-error.ts b/backend/src/services/learning-objects/processing/processing-error.ts new file mode 100644 index 00000000..b51f2a3a --- /dev/null +++ b/backend/src/services/learning-objects/processing/processing-error.ts @@ -0,0 +1,5 @@ +export class ProcessingError extends Error { + constructor(error: string) { + super(error); + } +} diff --git a/backend/src/services/learning-objects/processing/processing-service.ts b/backend/src/services/learning-objects/processing/processing-service.ts new file mode 100644 index 00000000..a6c662cc --- /dev/null +++ b/backend/src/services/learning-objects/processing/processing-service.ts @@ -0,0 +1,83 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/processing_proxy.js + */ + +import BlockImageProcessor from './image/block-image-processor.js'; +import InlineImageProcessor from './image/inline-image-processor.js'; +import { MarkdownProcessor } from './markdown/markdown-processor.js'; +import TextProcessor from './text/text-processor.js'; +import AudioProcessor from './audio/audio-processor.js'; +import PdfProcessor from './pdf/pdf-processor.js'; +import ExternProcessor from './extern/extern-processor.js'; +import GiftProcessor from './gift/gift-processor.js'; +import { LearningObject } from '../../../entities/content/learning-object.entity.js'; +import Processor from './processor.js'; +import { DwengoContentType } from './content-type.js'; +import { LearningObjectIdentifier } from '../../../interfaces/learning-content.js'; +import { Language } from '../../../entities/content/language.js'; +import { replaceAsync } from '../../../util/async.js'; + +const EMBEDDED_LEARNING_OBJECT_PLACEHOLDER = //g; +const LEARNING_OBJECT_DOES_NOT_EXIST = "
"; + +class ProcessingService { + private processors!: Map>; + + constructor() { + const processors = [ + new InlineImageProcessor(), + new BlockImageProcessor(), + new MarkdownProcessor(), + new TextProcessor(), + new AudioProcessor(), + new PdfProcessor(), + new ExternProcessor(), + new GiftProcessor(), + ]; + + this.processors = new Map(processors.map((processor) => [processor.contentType, processor])); + } + + /** + * Render the given learning object. + * @param learningObject The learning object to render + * @param fetchEmbeddedLearningObjects A function which takes a learning object identifier as an argument and + * returns the corresponding learning object. This is used to fetch learning + * objects embedded into this one. + * If this argument is omitted, embedded learning objects will be represented + * by placeholders. + * @returns Rendered HTML for this LearningObject as a string. + */ + async render( + learningObject: LearningObject, + fetchEmbeddedLearningObjects?: (loId: LearningObjectIdentifier) => Promise + ): Promise { + const html = this.processors.get(learningObject.contentType)!.renderLearningObject(learningObject); + if (fetchEmbeddedLearningObjects) { + // Replace all embedded learning objects. + return replaceAsync( + html, + EMBEDDED_LEARNING_OBJECT_PLACEHOLDER, + async (_, hruid: string, language: string, version: string): Promise => { + // Fetch the embedded learning object... + const learningObject = await fetchEmbeddedLearningObjects({ + hruid, + language: language as Language, + version: parseInt(version), + }); + + // If it does not exist, replace it by a placeholder. + if (!learningObject) { + return LEARNING_OBJECT_DOES_NOT_EXIST; + } + + // ... and render it. + return this.render(learningObject); + } + ); + } + return html; + } +} + +export default new ProcessingService(); diff --git a/backend/src/services/learning-objects/processing/processor.ts b/backend/src/services/learning-objects/processing/processor.ts new file mode 100644 index 00000000..a11c4416 --- /dev/null +++ b/backend/src/services/learning-objects/processing/processor.ts @@ -0,0 +1,61 @@ +import { LearningObject } from '../../../entities/content/learning-object.entity.js'; +import { ProcessingError } from './processing-error.js'; +import { DwengoContentType } from './content-type.js'; + +/** + * Abstract base class for all processors. + * Each processor is responsible for a specific format a learning object can be in, which i tcan render to HTML. + * + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/processor.js + */ +abstract class Processor { + protected constructor(public contentType: DwengoContentType) {} + + /** + * Render the given object. + * + * @param toRender Object which has to be rendered to HTML. This object has to be in the format for which this + * Processor is responsible. + * @return Rendered HTML-string + * @throws ProcessingError if the rendering fails. + */ + render(toRender: T): string { + return this.renderFn(toRender); + } + + /** + * Render a learning object with the content type for which this processor is responsible. + * @param toRender + */ + renderLearningObject(toRender: LearningObject): string { + if (toRender.contentType !== this.contentType) { + throw new ProcessingError( + `Unsupported content type: ${toRender.contentType}. + This processor is only responsible for content of type ${this.contentType}.` + ); + } + return this.renderLearningObjectFn(toRender); + } + + /** + * Function which actually renders the content. + * + * @param toRender Content to be rendered + * @return Rendered HTML as a string + * @protected + */ + protected abstract renderFn(toRender: T): string; + + /** + * Function which actually executes the rendering of a learning object. + * + * When implementing this function, we may assume that we are responsible for the content type of the learning + * object. + * + * @param toRender Learning object to render + * @protected + */ + protected abstract renderLearningObjectFn(toRender: LearningObject): string; +} + +export default Processor; diff --git a/backend/src/services/learning-objects/processing/string-processor.ts b/backend/src/services/learning-objects/processing/string-processor.ts new file mode 100644 index 00000000..f4734af9 --- /dev/null +++ b/backend/src/services/learning-objects/processing/string-processor.ts @@ -0,0 +1,19 @@ +import Processor from './processor.js'; +import { LearningObject } from '../../../entities/content/learning-object.entity.js'; + +export abstract class StringProcessor extends Processor { + /** + * Function which actually executes the rendering of a learning object. + * By default, this just means rendering the content in the content property of the learning object (interpreted + * as string) + * + * When implementing this function, we may assume that we are responsible for the content type of the learning + * object. + * + * @param toRender Learning object to render + * @protected + */ + protected renderLearningObjectFn(toRender: LearningObject): string { + return this.render(toRender.content.toString('ascii')); + } +} diff --git a/backend/src/services/learning-objects/processing/text/text-processor.ts b/backend/src/services/learning-objects/processing/text/text-processor.ts new file mode 100644 index 00000000..db6d37df --- /dev/null +++ b/backend/src/services/learning-objects/processing/text/text-processor.ts @@ -0,0 +1,20 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/text/text_processor.js + */ + +import DOMPurify from 'isomorphic-dompurify'; +import { DwengoContentType } from '../content-type.js'; +import { StringProcessor } from '../string-processor.js'; + +class TextProcessor extends StringProcessor { + constructor() { + super(DwengoContentType.TEXT_PLAIN); + } + + override renderFn(text: string) { + // Sanitize plain text to prevent xss. + return DOMPurify.sanitize(text); + } +} + +export default TextProcessor; diff --git a/backend/src/services/learning-paths/database-learning-path-provider.ts b/backend/src/services/learning-paths/database-learning-path-provider.ts new file mode 100644 index 00000000..fa8f42c6 --- /dev/null +++ b/backend/src/services/learning-paths/database-learning-path-provider.ts @@ -0,0 +1,190 @@ +import { LearningPathProvider } from './learning-path-provider.js'; +import { FilteredLearningObject, LearningObjectNode, LearningPath, LearningPathResponse, Transition } from '../../interfaces/learning-content.js'; +import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js'; +import { getLearningPathRepository } from '../../data/repositories.js'; +import { Language } from '../../entities/content/language.js'; +import learningObjectService from '../learning-objects/learning-object-service.js'; +import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; +import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; +import { getLastSubmissionForCustomizationTarget, isTransitionPossible, PersonalizationTarget } from './learning-path-personalization-util.js'; + +/** + * Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its + * corresponding learning object. + * @param nodes The nodes to find the learning object for. + */ +async function getLearningObjectsForNodes(nodes: LearningPathNode[]): Promise> { + // Fetching the corresponding learning object for each of the nodes and creating a map that maps each node to + // Its corresponding learning object. + const nullableNodesToLearningObjects = new Map( + await Promise.all( + nodes.map((node) => + learningObjectService + .getLearningObjectById({ + hruid: node.learningObjectHruid, + version: node.version, + language: node.language, + }) + .then((learningObject) => <[LearningPathNode, FilteredLearningObject | null]>[node, learningObject]) + ) + ) + ); + if (nullableNodesToLearningObjects.values().some((it) => it === null)) { + throw new Error('At least one of the learning objects on this path could not be found.'); + } + return nullableNodesToLearningObjects as Map; +} + +/** + * Convert the given learning path entity to an object which conforms to the learning path content. + */ +async function convertLearningPath(learningPath: LearningPathEntity, order: number, personalizedFor?: PersonalizationTarget): Promise { + const nodesToLearningObjects: Map = await getLearningObjectsForNodes(learningPath.nodes); + + const targetAges = nodesToLearningObjects + .values() + .flatMap((it) => it.targetAges || []) + .toArray(); + + const keywords = nodesToLearningObjects + .values() + .flatMap((it) => it.keywords || []) + .toArray(); + + const image = learningPath.image ? learningPath.image.toString('base64') : undefined; + + const convertedNodes = await convertNodes(nodesToLearningObjects, personalizedFor); + + return { + _id: `${learningPath.hruid}/${learningPath.language}`, // For backwards compatibility with the original Dwengo API. + __order: order, + hruid: learningPath.hruid, + language: learningPath.language, + description: learningPath.description, + image: image, + title: learningPath.title, + nodes: convertedNodes, + num_nodes: learningPath.nodes.length, + num_nodes_left: convertedNodes.filter((it) => !it.done).length, + keywords: keywords.join(' '), + target_ages: targetAges, + max_age: Math.max(...targetAges), + min_age: Math.min(...targetAges), + }; +} + +/** + * Helper function converting pairs of learning path nodes (as represented in the database) and the corresponding + * learning objects into a list of learning path nodes as they should be represented in the API. + * @param nodesToLearningObjects + * @param personalizedFor + */ +async function convertNodes( + nodesToLearningObjects: Map, + personalizedFor?: PersonalizationTarget +): Promise { + const nodesPromise = nodesToLearningObjects + .entries() + .map(async (entry) => { + const [node, learningObject] = entry; + const lastSubmission = personalizedFor ? await getLastSubmissionForCustomizationTarget(node, personalizedFor) : null; + return { + _id: learningObject.uuid, + language: learningObject.language, + start_node: node.startNode, + created_at: node.createdAt.toISOString(), + updatedAt: node.updatedAt.toISOString(), + learningobject_hruid: node.learningObjectHruid, + version: learningObject.version, + transitions: node.transitions + .filter( + (trans) => !personalizedFor || isTransitionPossible(trans, optionalJsonStringToObject(lastSubmission?.content)) // If we want a personalized learning path, remove all transitions that aren't possible. + ) + .map((trans, i) => convertTransition(trans, i, nodesToLearningObjects)), // Then convert all the transition + }; + }) + .toArray(); + return await Promise.all(nodesPromise); +} + +/** + * Helper method to convert a json string to an object, or null if it is undefined. + */ +function optionalJsonStringToObject(jsonString?: string): object | null { + if (!jsonString) { + return null; + } + return JSON.parse(jsonString); +} + +/** + * Helper function which converts a transition in the database representation to a transition in the representation + * the Dwengo API uses. + * + * @param transition + * @param index + * @param nodesToLearningObjects + */ +function convertTransition( + transition: LearningPathTransition, + index: number, + nodesToLearningObjects: Map +): Transition { + const nextNode = nodesToLearningObjects.get(transition.next); + if (!nextNode) { + throw new Error(`Learning object ${transition.next.learningObjectHruid}/${transition.next.language}/${transition.next.version} not found!`); + } else { + return { + _id: '' + index, // Retained for backwards compatibility. The index uniquely identifies the transition within the learning path. + default: false, // We don't work with default transitions but retain this for backwards compatibility. + next: { + _id: nextNode._id + index, // Construct a unique ID for the transition for backwards compatibility. + hruid: transition.next.learningObjectHruid, + language: nextNode.language, + version: nextNode.version, + }, + }; + } +} + +/** + * Service providing access to data about learning paths from the database. + */ +const databaseLearningPathProvider: LearningPathProvider = { + /** + * Fetch the learning paths with the given hruids from the database. + */ + async fetchLearningPaths( + hruids: string[], + language: Language, + source: string, + personalizedFor?: PersonalizationTarget + ): Promise { + const learningPathRepo = getLearningPathRepository(); + + const learningPaths = (await Promise.all(hruids.map((hruid) => learningPathRepo.findByHruidAndLanguage(hruid, language)))).filter( + (learningPath) => learningPath !== null + ); + const filteredLearningPaths = await Promise.all( + learningPaths.map((learningPath, index) => convertLearningPath(learningPath, index, personalizedFor)) + ); + + return { + success: filteredLearningPaths.length > 0, + data: await Promise.all(filteredLearningPaths), + source, + }; + }, + + /** + * Search learning paths in the database using the given search string. + */ + async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise { + const learningPathRepo = getLearningPathRepository(); + + const searchResults = await learningPathRepo.findByQueryStringAndLanguage(query, language); + return await Promise.all(searchResults.map((result, index) => convertLearningPath(result, index, personalizedFor))); + }, +}; + +export default databaseLearningPathProvider; diff --git a/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts b/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts new file mode 100644 index 00000000..2b1d17a6 --- /dev/null +++ b/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts @@ -0,0 +1,50 @@ +import { fetchWithLogging } from '../../util/apiHelper.js'; +import { DWENGO_API_BASE } from '../../config.js'; +import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js'; +import { LearningPathProvider } from './learning-path-provider.js'; +import { getLogger, Logger } from '../../logging/initalize.js'; + +const logger: Logger = getLogger(); + +const dwengoApiLearningPathProvider: LearningPathProvider = { + async fetchLearningPaths(hruids: string[], language: string, source: string): Promise { + if (hruids.length === 0) { + return { + success: false, + source, + data: null, + message: `No HRUIDs provided for ${source}.`, + }; + } + + const apiUrl = `${DWENGO_API_BASE}/learningPath/getPathsFromIdList`; + const params = { pathIdList: JSON.stringify({ hruids }), language }; + + const learningPaths = await fetchWithLogging(apiUrl, `Learning paths for ${source}`, { params }); + + if (!learningPaths || learningPaths.length === 0) { + logger.warn(`⚠️ WARNING: No learning paths found for ${source}.`); + return { + success: false, + source, + data: [], + message: `No learning paths found for ${source}.`, + }; + } + + return { + success: true, + source, + data: learningPaths, + }; + }, + async searchLearningPaths(query: string, language: string): Promise { + const apiUrl = `${DWENGO_API_BASE}/learningPath/search`; + const params = { all: query, language }; + + const searchResults = await fetchWithLogging(apiUrl, `Search learning paths with query "${query}"`, { params }); + return searchResults ?? []; + }, +}; + +export default dwengoApiLearningPathProvider; diff --git a/backend/src/services/learning-paths/learning-path-personalization-util.ts b/backend/src/services/learning-paths/learning-path-personalization-util.ts new file mode 100644 index 00000000..a9175d13 --- /dev/null +++ b/backend/src/services/learning-paths/learning-path-personalization-util.ts @@ -0,0 +1,90 @@ +import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; +import { Student } from '../../entities/users/student.entity.js'; +import { Group } from '../../entities/assignments/group.entity.js'; +import { Submission } from '../../entities/assignments/submission.entity.js'; +import { getClassRepository, getGroupRepository, getStudentRepository, getSubmissionRepository } from '../../data/repositories.js'; +import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; +import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; +import { JSONPath } from 'jsonpath-plus'; + +export type PersonalizationTarget = { type: 'student'; student: Student } | { type: 'group'; group: Group }; + +/** + * Shortcut function to easily create a PersonalizationTarget object for a student by his/her username. + * @param username Username of the student we want to generate a personalized learning path for. + * If there is no student with this username, return undefined. + */ +export async function personalizedForStudent(username: string): Promise { + const student = await getStudentRepository().findByUsername(username); + if (student) { + return { + type: 'student', + student: student, + }; + } + return undefined; +} + +/** + * Shortcut function to easily create a PersonalizationTarget object for a group by class name, assignment number and + * group number. + * @param classId Id of the class in which this group was created + * @param assignmentNumber Number of the assignment for which this group was created + * @param groupNumber Number of the group for which we want to personalize the learning path. + */ +export async function personalizedForGroup( + classId: string, + assignmentNumber: number, + groupNumber: number +): Promise { + const clazz = await getClassRepository().findById(classId); + if (!clazz) { + return undefined; + } + const group = await getGroupRepository().findOne({ + assignment: { + within: clazz, + id: assignmentNumber, + }, + groupNumber: groupNumber, + }); + if (group) { + return { + type: 'group', + group: group, + }; + } + return undefined; +} + +/** + * Returns the last submission for the learning object associated with the given node and for the student or group + */ +export async function getLastSubmissionForCustomizationTarget(node: LearningPathNode, pathFor: PersonalizationTarget): Promise { + const submissionRepo = getSubmissionRepository(); + const learningObjectId: LearningObjectIdentifier = { + hruid: node.learningObjectHruid, + language: node.language, + version: node.version, + }; + if (pathFor.type === 'group') { + return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor.group); + } + return await submissionRepo.findMostRecentSubmissionForStudent(learningObjectId, pathFor.student); +} + +/** + * Checks whether the condition of the given transaction is fulfilled by the given submission. + * @param transition + * @param submitted + */ +export function isTransitionPossible(transition: LearningPathTransition, submitted: object | null): boolean { + if (transition.condition === 'true' || !transition.condition) { + return true; // If the transition is unconditional, we can go on. + } + if (submitted === null) { + return false; // If the transition is not unconditional and there was no submission, the transition is not possible. + } + const match = JSONPath({ path: transition.condition, json: { submission: submitted } }); + return match.length === 1; +} diff --git a/backend/src/services/learning-paths/learning-path-provider.ts b/backend/src/services/learning-paths/learning-path-provider.ts new file mode 100644 index 00000000..5e2a09df --- /dev/null +++ b/backend/src/services/learning-paths/learning-path-provider.ts @@ -0,0 +1,18 @@ +import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js'; +import { Language } from '../../entities/content/language.js'; +import { PersonalizationTarget } from './learning-path-personalization-util.js'; + +/** + * Generic interface for a service which provides access to learning paths from a data source. + */ +export interface LearningPathProvider { + /** + * Fetch the learning paths with the given hruids from the data source. + */ + fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: PersonalizationTarget): Promise; + + /** + * Search learning paths in the data source using the given search string. + */ + searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise; +} diff --git a/backend/src/services/learning-paths/learning-path-service.ts b/backend/src/services/learning-paths/learning-path-service.ts new file mode 100644 index 00000000..2fceb46c --- /dev/null +++ b/backend/src/services/learning-paths/learning-path-service.ts @@ -0,0 +1,57 @@ +import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js'; +import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider.js'; +import databaseLearningPathProvider from './database-learning-path-provider.js'; +import { EnvVars, getEnvVar } from '../../util/envvars.js'; +import { Language } from '../../entities/content/language.js'; +import { PersonalizationTarget } from './learning-path-personalization-util.js'; + +const userContentPrefix = getEnvVar(EnvVars.UserContentPrefix); +const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider]; + +/** + * Service providing access to data about learning paths from the appropriate data source (database or Dwengo-api) + */ +const learningPathService = { + /** + * Fetch the learning paths with the given hruids from the data source. + * @param hruids For each of the hruids, the learning path will be fetched. + * @param language This is the language each of the learning paths will use. + * @param source + * @param personalizedFor If this is set, a learning path personalized for the given group or student will be returned. + */ + async fetchLearningPaths( + hruids: string[], + language: Language, + source: string, + personalizedFor?: PersonalizationTarget + ): Promise { + const userContentHruids = hruids.filter((hruid) => hruid.startsWith(userContentPrefix)); + const nonUserContentHruids = hruids.filter((hruid) => !hruid.startsWith(userContentPrefix)); + + const userContentLearningPaths = await databaseLearningPathProvider.fetchLearningPaths(userContentHruids, language, source, personalizedFor); + const nonUserContentLearningPaths = await dwengoApiLearningPathProvider.fetchLearningPaths( + nonUserContentHruids, + language, + source, + personalizedFor + ); + + const result = (userContentLearningPaths.data || []).concat(nonUserContentLearningPaths.data || []); + + return { + data: result, + source: source, + success: userContentLearningPaths.success || nonUserContentLearningPaths.success, + }; + }, + + /** + * Search learning paths in the data source using the given search string. + */ + async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise { + const providerResponses = await Promise.all(allProviders.map((provider) => provider.searchLearningPaths(query, language, personalizedFor))); + return providerResponses.flat(); + }, +}; + +export default learningPathService; diff --git a/backend/src/services/learningObjects.ts b/backend/src/services/learningObjects.ts deleted file mode 100644 index 5ea9f3aa..00000000 --- a/backend/src/services/learningObjects.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { DWENGO_API_BASE } from '../config.js'; -import { fetchWithLogging } from '../util/apiHelper.js'; -import { FilteredLearningObject, LearningObjectMetadata, LearningObjectNode, LearningPathResponse } from '../interfaces/learningPath.js'; -import { fetchLearningPaths } from './learningPaths.js'; -import { getLogger, Logger } from '../logging/initalize.js'; - -const logger: Logger = getLogger(); - -function filterData(data: LearningObjectMetadata, htmlUrl: string): FilteredLearningObject { - return { - key: data.hruid, // Hruid learningObject (not path) - _id: data._id, - uuid: data.uuid, - version: data.version, - title: data.title, - htmlUrl, // Url to fetch html content - language: data.language, - difficulty: data.difficulty, - estimatedTime: data.estimated_time, - available: data.available, - teacherExclusive: data.teacher_exclusive, - educationalGoals: data.educational_goals, // List with learningObjects - keywords: data.keywords, // For search - description: data.description, // For search (not an actual description) - targetAges: data.target_ages, - contentType: data.content_type, // Markdown, image, audio, etc. - contentLocation: data.content_location, // If content type extern - skosConcepts: data.skos_concepts, - returnValue: data.return_value, // Callback response information - }; -} - -/** - * Fetches a single learning object by its HRUID - */ -export async function getLearningObjectById(hruid: string, language: string): Promise { - const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata?hruid=${hruid}&language=${language}`; - const metadata = await fetchWithLogging( - metadataUrl, - `Metadata for Learning Object HRUID "${hruid}" (language ${language})` - ); - - if (!metadata) { - logger.warn(`⚠️ WARNING: Learning object "${hruid}" not found.`); - return null; - } - - const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw?hruid=${hruid}&language=${language}`; - return filterData(metadata, htmlUrl); -} - -/** - * Generic function to fetch learning objects (full data or just HRUIDs) - */ -async function fetchLearningObjects(hruid: string, full: boolean, language: string): Promise { - try { - const learningPathResponse: LearningPathResponse = await fetchLearningPaths([hruid], language, `Learning path for HRUID "${hruid}"`); - - if (!learningPathResponse.success || !learningPathResponse.data?.length) { - logger.warn(`⚠️ WARNING: Learning path "${hruid}" exists but contains no learning objects.`); - return []; - } - - const nodes: LearningObjectNode[] = learningPathResponse.data[0].nodes; - - if (!full) { - return nodes.map((node) => node.learningobject_hruid); - } - - return await Promise.all(nodes.map(async (node) => getLearningObjectById(node.learningobject_hruid, language))).then((objects) => - objects.filter((obj): obj is FilteredLearningObject => obj !== null) - ); - } catch (error) { - logger.error('❌ Error fetching learning objects:', error); - return []; - } -} - -/** - * Fetch full learning object data (metadata) - */ -export async function getLearningObjectsFromPath(hruid: string, language: string): Promise { - return (await fetchLearningObjects(hruid, true, language)) as FilteredLearningObject[]; -} - -/** - * Fetch only learning object HRUIDs - */ -export async function getLearningObjectIdsFromPath(hruid: string, language: string): Promise { - return (await fetchLearningObjects(hruid, false, language)) as string[]; -} diff --git a/backend/src/services/learningPaths.ts b/backend/src/services/learningPaths.ts deleted file mode 100644 index 7a32cd7b..00000000 --- a/backend/src/services/learningPaths.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { fetchWithLogging } from '../util/apiHelper.js'; -import { DWENGO_API_BASE } from '../config.js'; -import { LearningPath, LearningPathResponse } from '../interfaces/learningPath.js'; -import { getLogger, Logger } from '../logging/initalize.js'; - -const logger: Logger = getLogger(); - -export async function fetchLearningPaths(hruids: string[], language: string, source: string): Promise { - if (hruids.length === 0) { - return { - success: false, - source, - data: null, - message: `No HRUIDs provided for ${source}.`, - }; - } - - const apiUrl = `${DWENGO_API_BASE}/learningPath/getPathsFromIdList`; - const params = { pathIdList: JSON.stringify({ hruids }), language }; - - const learningPaths = await fetchWithLogging(apiUrl, `Learning paths for ${source}`, params); - - if (!learningPaths || learningPaths.length === 0) { - logger.warn(`⚠️ WARNING: No learning paths found for ${source}.`); - return { - success: false, - source, - data: [], - message: `No learning paths found for ${source}.`, - }; - } - - return { - success: true, - source, - data: learningPaths, - }; -} - -export async function searchLearningPaths(query: string, language: string): Promise { - const apiUrl = `${DWENGO_API_BASE}/learningPath/search`; - const params = { all: query, language }; - - const searchResults = await fetchWithLogging(apiUrl, `Search learning paths with query "${query}"`, params); - return searchResults ?? []; -} diff --git a/backend/src/sqlite-autoincrement-workaround.ts b/backend/src/sqlite-autoincrement-workaround.ts new file mode 100644 index 00000000..a5c20dfd --- /dev/null +++ b/backend/src/sqlite-autoincrement-workaround.ts @@ -0,0 +1,41 @@ +import { EntityProperty, EventArgs, EventSubscriber } from '@mikro-orm/core'; + +/** + * The tests are ran on an in-memory SQLite database. However, SQLite does not allow fields which are part of composite + * primary keys to be autoincremented (while PostgreSQL, which we use in production, does). This Subscriber works around + * the issue by remembering the highest values for every autoincremented part of a primary key and assigning them when + * creating a new entity. + * + * However, it is important to note the following limitations: + * - this class can only be used for in-memory SQLite databases since the information on what the highest sequence + * number for each of the properties is, is only saved transiently. + * - automatically setting the generated "autoincremented" value for properties only works when the entity is created + * via an entityManager.create(...) or repo.create(...) method. Otherwise, onInit will not be called and therefore, + * the sequence number will not be filled in. + */ +export class SqliteAutoincrementSubscriber implements EventSubscriber { + private sequenceNumbersForEntityType: Map = new Map(); + + /** + * When an entity with an autoincremented property which is part of the composite private key is created, + * automatically fill this property so we won't face not-null-constraint exceptions when persisting it. + */ + onInit(args: EventArgs): void { + if (!args.meta.compositePK) { + return; // If there is not a composite primary key, autoincrement works fine with SQLite anyway. + } + + for (const prop of Object.values(args.meta.properties)) { + const property = prop as EntityProperty; + if (property.primary && property.autoincrement && !(args.entity as Record)[property.name]) { + // Obtain and increment sequence number of this entity. + const propertyKey = args.meta.class.name + '.' + property.name; + const nextSeqNumber = this.sequenceNumbersForEntityType.get(propertyKey) || 0; + this.sequenceNumbersForEntityType.set(propertyKey, nextSeqNumber + 1); + + // Set the property accordingly. + (args.entity as Record)[property.name] = nextSeqNumber + 1; + } + } + } +} diff --git a/backend/src/util/apiHelper.ts b/backend/src/util/apiHelper.ts index b8c1b943..fff7a6a1 100644 --- a/backend/src/util/apiHelper.ts +++ b/backend/src/util/apiHelper.ts @@ -9,13 +9,21 @@ const logger: Logger = getLogger(); * * @param url The API endpoint to fetch from. * @param description A short description of what is being fetched (for logging). - * @param params + * @param options Contains further options such as params (the query params) and responseType (whether the response + * should be parsed as JSON ("json") or whether it should be returned as plain text ("text") * @returns The response data if successful, or null if an error occurs. */ -export async function fetchWithLogging(url: string, description: string, params?: Record): Promise { +export async function fetchWithLogging( + url: string, + description: string, + options?: { + params?: Record; + query?: Record; + responseType?: 'json' | 'text'; + } +): Promise { try { - const config: AxiosRequestConfig = params ? { params } : {}; - + const config: AxiosRequestConfig = options || {}; const response = await axios.get(url, config); return response.data; } catch (error: any) { diff --git a/backend/src/util/async.ts b/backend/src/util/async.ts new file mode 100644 index 00000000..a5fc9b82 --- /dev/null +++ b/backend/src/util/async.ts @@ -0,0 +1,23 @@ +/** + * Replace all occurrences of regex in str with the result of asyncFn called with the matching snippet and each of + * the parts matched by a group in the regex as arguments. + * + * @param str The string where to replace the occurrences + * @param regex + * @param replacementFn + */ +export async function replaceAsync(str: string, regex: RegExp, replacementFn: (match: string, ...args: string[]) => Promise) { + const promises: Promise[] = []; + + // First run through matches: add all Promises resulting from the replacement function + str.replace(regex, (full, ...args) => { + promises.push(replacementFn(full, ...args)); + return full; + }); + + // Wait for the replacements to get loaded. Reverse them so when popping them, we work in a FIFO manner. + const replacements: string[] = await Promise.all(promises); + + // Second run through matches: Replace them by their previously computed replacements. + return str.replace(regex, () => replacements.pop()!); +} diff --git a/backend/src/util/envvars.ts b/backend/src/util/envvars.ts index 43940244..115291af 100644 --- a/backend/src/util/envvars.ts +++ b/backend/src/util/envvars.ts @@ -15,6 +15,9 @@ export const EnvVars: { [key: string]: EnvVar } = { DbUsername: { key: DB_PREFIX + 'USERNAME', required: true }, DbPassword: { key: DB_PREFIX + 'PASSWORD', required: true }, DbUpdate: { key: DB_PREFIX + 'UPDATE', defaultValue: false }, + LearningContentRepoApiBaseUrl: { key: PREFIX + 'LEARNING_CONTENT_REPO_API_BASE_URL', defaultValue: 'https://dwengo.org/backend/api' }, + FallbackLanguage: { key: PREFIX + 'FALLBACK_LANGUAGE', defaultValue: 'nl' }, + UserContentPrefix: { key: DB_PREFIX + 'USER_CONTENT_PREFIX', defaultValue: 'u_' }, IdpStudentUrl: { key: STUDENT_IDP_PREFIX + 'URL', required: true }, IdpStudentClientId: { key: STUDENT_IDP_PREFIX + 'CLIENT_ID', required: true }, IdpStudentJwksEndpoint: { key: STUDENT_IDP_PREFIX + 'JWKS_ENDPOINT', required: true }, diff --git a/backend/src/util/links.ts b/backend/src/util/links.ts new file mode 100644 index 00000000..73e27965 --- /dev/null +++ b/backend/src/util/links.ts @@ -0,0 +1,26 @@ +import { LearningObjectIdentifier } from '../interfaces/learning-content'; + +export function isValidHttpUrl(url: string): boolean { + try { + const parsedUrl = new URL(url, 'http://test.be'); + return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:'; + } catch (e) { + return false; + } +} + +export function getUrlStringForLearningObject(learningObjectId: LearningObjectIdentifier) { + let url = `/learningObject/${learningObjectId.hruid}/html?language=${learningObjectId.language}`; + if (learningObjectId.version) { + url += `&version=${learningObjectId.version}`; + } + return url; +} + +export function getUrlStringForLearningObjectHTML(learningObjectIdentifier: LearningObjectIdentifier): string { + let url = `/learningObject/${learningObjectIdentifier.hruid}/html?language=${learningObjectIdentifier.language}`; + if (learningObjectIdentifier.version) { + url += `&version=${learningObjectIdentifier.version}`; + } + return url; +} diff --git a/backend/tests/data/assignments/assignments.test.ts b/backend/tests/data/assignments/assignments.test.ts new file mode 100644 index 00000000..c26fb5ba --- /dev/null +++ b/backend/tests/data/assignments/assignments.test.ts @@ -0,0 +1,42 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; +import { getAssignmentRepository, getClassRepository } from '../../../src/data/repositories'; +import { ClassRepository } from '../../../src/data/classes/class-repository'; + +describe('AssignmentRepository', () => { + let assignmentRepository: AssignmentRepository; + let classRepository: ClassRepository; + + beforeAll(async () => { + await setupTestApp(); + assignmentRepository = getAssignmentRepository(); + classRepository = getClassRepository(); + }); + + it('should return the requested assignment', async () => { + const class_ = await classRepository.findById('id02'); + const assignment = await assignmentRepository.findByClassAndId(class_!, 2); + + expect(assignment).toBeTruthy(); + expect(assignment!.title).toBe('tool'); + }); + + it('should return all assignments for a class', async () => { + const class_ = await classRepository.findById('id02'); + const assignments = await assignmentRepository.findAllAssignmentsInClass(class_!); + + expect(assignments).toBeTruthy(); + expect(assignments).toHaveLength(1); + expect(assignments[0].title).toBe('tool'); + }); + + it('should not find removed assignment', async () => { + const class_ = await classRepository.findById('id01'); + await assignmentRepository.deleteByClassAndId(class_!, 3); + + const assignment = await assignmentRepository.findByClassAndId(class_!, 3); + + expect(assignment).toBeNull(); + }); +}); diff --git a/backend/tests/data/assignments/groups.test.ts b/backend/tests/data/assignments/groups.test.ts new file mode 100644 index 00000000..96684d68 --- /dev/null +++ b/backend/tests/data/assignments/groups.test.ts @@ -0,0 +1,49 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { GroupRepository } from '../../../src/data/assignments/group-repository'; +import { getAssignmentRepository, getClassRepository, getGroupRepository } from '../../../src/data/repositories'; +import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; +import { ClassRepository } from '../../../src/data/classes/class-repository'; + +describe('GroupRepository', () => { + let groupRepository: GroupRepository; + let assignmentRepository: AssignmentRepository; + let classRepository: ClassRepository; + + beforeAll(async () => { + await setupTestApp(); + groupRepository = getGroupRepository(); + assignmentRepository = getAssignmentRepository(); + classRepository = getClassRepository(); + }); + + it('should return the requested group', async () => { + const class_ = await classRepository.findById('id01'); + const assignment = await assignmentRepository.findByClassAndId(class_!, 1); + + const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 1); + + expect(group).toBeTruthy(); + }); + + it('should return all groups for assignment', async () => { + const class_ = await classRepository.findById('id01'); + const assignment = await assignmentRepository.findByClassAndId(class_!, 1); + + const groups = await groupRepository.findAllGroupsForAssignment(assignment!); + + expect(groups).toBeTruthy(); + expect(groups).toHaveLength(3); + }); + + it('should not find removed group', async () => { + const class_ = await classRepository.findById('id02'); + const assignment = await assignmentRepository.findByClassAndId(class_!, 2); + + await groupRepository.deleteByAssignmentAndGroupNumber(assignment!, 1); + + const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 1); + + expect(group).toBeNull(); + }); +}); diff --git a/backend/tests/data/assignments/submissions.test.ts b/backend/tests/data/assignments/submissions.test.ts new file mode 100644 index 00000000..8712a710 --- /dev/null +++ b/backend/tests/data/assignments/submissions.test.ts @@ -0,0 +1,70 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { SubmissionRepository } from '../../../src/data/assignments/submission-repository'; +import { + getAssignmentRepository, + getClassRepository, + getGroupRepository, + getStudentRepository, + getSubmissionRepository, +} from '../../../src/data/repositories'; +import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier'; +import { Language } from '../../../src/entities/content/language'; +import { StudentRepository } from '../../../src/data/users/student-repository'; +import { GroupRepository } from '../../../src/data/assignments/group-repository'; +import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; +import { ClassRepository } from '../../../src/data/classes/class-repository'; + +describe('SubmissionRepository', () => { + let submissionRepository: SubmissionRepository; + let studentRepository: StudentRepository; + let groupRepository: GroupRepository; + let assignmentRepository: AssignmentRepository; + let classRepository: ClassRepository; + + beforeAll(async () => { + await setupTestApp(); + submissionRepository = getSubmissionRepository(); + studentRepository = getStudentRepository(); + groupRepository = getGroupRepository(); + assignmentRepository = getAssignmentRepository(); + classRepository = getClassRepository(); + }); + + it('should find the requested submission', async () => { + const id = new LearningObjectIdentifier('id03', Language.English, '1'); + const submission = await submissionRepository.findSubmissionByLearningObjectAndSubmissionNumber(id, 1); + + expect(submission).toBeTruthy(); + expect(submission?.content).toBe('sub1'); + }); + + it('should find the most recent submission for a student', async () => { + const id = new LearningObjectIdentifier('id02', Language.English, '1'); + const student = await studentRepository.findByUsername('Noordkaap'); + const submission = await submissionRepository.findMostRecentSubmissionForStudent(id, student!); + + expect(submission).toBeTruthy(); + expect(submission?.submissionTime.getDate()).toBe(25); + }); + + it('should find the most recent submission for a group', async () => { + const id = new LearningObjectIdentifier('id03', Language.English, '1'); + const class_ = await classRepository.findById('id01'); + const assignment = await assignmentRepository.findByClassAndId(class_!, 1); + const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 1); + const submission = await submissionRepository.findMostRecentSubmissionForGroup(id, group!); + + expect(submission).toBeTruthy(); + expect(submission?.submissionTime.getDate()).toBe(25); + }); + + it('should not find a deleted submission', async () => { + const id = new LearningObjectIdentifier('id01', Language.English, '1'); + await submissionRepository.deleteSubmissionByLearningObjectAndSubmissionNumber(id, 1); + + const submission = await submissionRepository.findSubmissionByLearningObjectAndSubmissionNumber(id, 1); + + expect(submission).toBeNull(); + }); +}); diff --git a/backend/tests/data/classes/class-join-request.test.ts b/backend/tests/data/classes/class-join-request.test.ts new file mode 100644 index 00000000..f0aa2f62 --- /dev/null +++ b/backend/tests/data/classes/class-join-request.test.ts @@ -0,0 +1,47 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { ClassJoinRequestRepository } from '../../../src/data/classes/class-join-request-repository'; +import { getClassJoinRequestRepository, getClassRepository, getStudentRepository } from '../../../src/data/repositories'; +import { StudentRepository } from '../../../src/data/users/student-repository'; +import { Class } from '../../../src/entities/classes/class.entity'; +import { ClassRepository } from '../../../src/data/classes/class-repository'; +import { Student } from '../../../src/entities/users/student.entity'; + +describe('ClassJoinRequestRepository', () => { + let classJoinRequestRepository: ClassJoinRequestRepository; + let studentRepository: StudentRepository; + let cassRepository: ClassRepository; + + beforeAll(async () => { + await setupTestApp(); + classJoinRequestRepository = getClassJoinRequestRepository(); + studentRepository = getStudentRepository(); + cassRepository = getClassRepository(); + }); + + it('should list all requests from student to join classes', async () => { + const student = await studentRepository.findByUsername('PinkFloyd'); + const requests = await classJoinRequestRepository.findAllRequestsBy(student!); + + expect(requests).toBeTruthy(); + expect(requests).toHaveLength(2); + }); + + it('should list all requests to a single class', async () => { + const class_ = await cassRepository.findById('id02'); + const requests = await classJoinRequestRepository.findAllOpenRequestsTo(class_!); + + expect(requests).toBeTruthy(); + expect(requests).toHaveLength(2); + }); + + it('should not find a removed request', async () => { + const student = await studentRepository.findByUsername('SmashingPumpkins'); + const class_ = await cassRepository.findById('id03'); + await classJoinRequestRepository.deleteBy(student!, class_!); + + const request = await classJoinRequestRepository.findAllRequestsBy(student!); + + expect(request).toHaveLength(0); + }); +}); diff --git a/backend/tests/data/classes/classes.test.ts b/backend/tests/data/classes/classes.test.ts new file mode 100644 index 00000000..22306ba6 --- /dev/null +++ b/backend/tests/data/classes/classes.test.ts @@ -0,0 +1,34 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { ClassRepository } from '../../../src/data/classes/class-repository'; +import { setupTestApp } from '../../setup-tests'; +import { getClassRepository } from '../../../src/data/repositories'; + +describe('ClassRepository', () => { + let classRepository: ClassRepository; + + beforeAll(async () => { + await setupTestApp(); + classRepository = getClassRepository(); + }); + + it('should return nothing because id does not exist', async () => { + const classVar = await classRepository.findById('test_id'); + + expect(classVar).toBeNull(); + }); + + it('should return requested class', async () => { + const classVar = await classRepository.findById('id01'); + + expect(classVar).toBeTruthy(); + expect(classVar?.displayName).toBe('class01'); + }); + + it('class should be gone after deletion', async () => { + await classRepository.deleteById('id04'); + + const classVar = await classRepository.findById('id04'); + + expect(classVar).toBeNull(); + }); +}); diff --git a/backend/tests/data/classes/teacher-invitation.test.ts b/backend/tests/data/classes/teacher-invitation.test.ts new file mode 100644 index 00000000..dd03634a --- /dev/null +++ b/backend/tests/data/classes/teacher-invitation.test.ts @@ -0,0 +1,54 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { getClassRepository, getTeacherInvitationRepository, getTeacherRepository } from '../../../src/data/repositories'; +import { TeacherInvitationRepository } from '../../../src/data/classes/teacher-invitation-repository'; +import { TeacherRepository } from '../../../src/data/users/teacher-repository'; +import { ClassRepository } from '../../../src/data/classes/class-repository'; + +describe('ClassRepository', () => { + let teacherInvitationRepository: TeacherInvitationRepository; + let teacherRepository: TeacherRepository; + let classRepository: ClassRepository; + + beforeAll(async () => { + await setupTestApp(); + teacherInvitationRepository = getTeacherInvitationRepository(); + teacherRepository = getTeacherRepository(); + classRepository = getClassRepository(); + }); + + it('should return all invitations from a teacher', async () => { + const teacher = await teacherRepository.findByUsername('LimpBizkit'); + const invitations = await teacherInvitationRepository.findAllInvitationsBy(teacher!); + + expect(invitations).toBeTruthy(); + expect(invitations).toHaveLength(2); + }); + + it('should return all invitations for a teacher', async () => { + const teacher = await teacherRepository.findByUsername('FooFighters'); + const invitations = await teacherInvitationRepository.findAllInvitationsFor(teacher!); + + expect(invitations).toBeTruthy(); + expect(invitations).toHaveLength(2); + }); + + it('should return all invitations for a class', async () => { + const class_ = await classRepository.findById('id02'); + const invitations = await teacherInvitationRepository.findAllInvitationsForClass(class_!); + + expect(invitations).toBeTruthy(); + expect(invitations).toHaveLength(2); + }); + + it('should not find a removed invitation', async () => { + const class_ = await classRepository.findById('id01'); + const sender = await teacherRepository.findByUsername('FooFighters'); + const receiver = await teacherRepository.findByUsername('LimpBizkit'); + await teacherInvitationRepository.deleteBy(class_!, sender!, receiver!); + + const invitation = await teacherInvitationRepository.findAllInvitationsBy(sender!); + + expect(invitation).toHaveLength(0); + }); +}); diff --git a/backend/tests/data/content/attachment-repository.test.ts b/backend/tests/data/content/attachment-repository.test.ts new file mode 100644 index 00000000..85c1f1c5 --- /dev/null +++ b/backend/tests/data/content/attachment-repository.test.ts @@ -0,0 +1,79 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests.js'; +import { getAttachmentRepository, getLearningObjectRepository } from '../../../src/data/repositories.js'; +import { AttachmentRepository } from '../../../src/data/content/attachment-repository.js'; +import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js'; +import example from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.js'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity.js'; +import { Attachment } from '../../../src/entities/content/attachment.entity.js'; +import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier.js'; + +const NEWER_TEST_SUFFIX = 'nEweR'; + +function createTestLearningObjects(learningObjectRepo: LearningObjectRepository): { older: LearningObject; newer: LearningObject } { + const olderExample = example.createLearningObject(); + learningObjectRepo.save(olderExample); + + const newerExample = example.createLearningObject(); + newerExample.title = 'Newer example'; + newerExample.version = 100; + + return { + older: olderExample, + newer: newerExample, + }; +} + +describe('AttachmentRepository', () => { + let attachmentRepo: AttachmentRepository; + let exampleLearningObjects: { older: LearningObject; newer: LearningObject }; + let attachmentsOlderLearningObject: Attachment[]; + + beforeAll(async () => { + await setupTestApp(); + attachmentRepo = getAttachmentRepository(); + exampleLearningObjects = createTestLearningObjects(getLearningObjectRepository()); + }); + + it('can add attachments to learning objects without throwing an error', () => { + attachmentsOlderLearningObject = Object.values(example.createAttachment).map((fn) => fn(exampleLearningObjects.older)); + + for (const attachment of attachmentsOlderLearningObject) { + attachmentRepo.save(attachment); + } + }); + + let attachmentOnlyNewer: Attachment; + it('allows us to add attachments with the same name to a different learning object without throwing an error', () => { + attachmentOnlyNewer = Object.values(example.createAttachment)[0](exampleLearningObjects.newer); + attachmentOnlyNewer.content.write(NEWER_TEST_SUFFIX); + + attachmentRepo.save(attachmentOnlyNewer); + }); + + let olderLearningObjectId: LearningObjectIdentifier; + it('returns the correct attachment when queried by learningObjectId and attachment name', async () => { + olderLearningObjectId = { + hruid: exampleLearningObjects.older.hruid, + language: exampleLearningObjects.older.language, + version: exampleLearningObjects.older.version, + }; + + const result = await attachmentRepo.findByLearningObjectIdAndName(olderLearningObjectId, attachmentsOlderLearningObject[0].name); + expect(result).toBe(attachmentsOlderLearningObject[0]); + }); + + it('returns null when queried by learningObjectId and non-existing attachment name', async () => { + const result = await attachmentRepo.findByLearningObjectIdAndName(olderLearningObjectId, 'non-existing name'); + expect(result).toBe(null); + }); + + it('returns the newer version of the attachment when only queried by hruid, language and attachment name (but not version)', async () => { + const result = await attachmentRepo.findByMostRecentVersionOfLearningObjectAndName( + exampleLearningObjects.older.hruid, + exampleLearningObjects.older.language, + attachmentOnlyNewer.name + ); + expect(result).toBe(attachmentOnlyNewer); + }); +}); diff --git a/backend/tests/data/content/attachments.test.ts b/backend/tests/data/content/attachments.test.ts new file mode 100644 index 00000000..a8bea88a --- /dev/null +++ b/backend/tests/data/content/attachments.test.ts @@ -0,0 +1,31 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests.js'; +import { getAttachmentRepository, getLearningObjectRepository } from '../../../src/data/repositories.js'; +import { AttachmentRepository } from '../../../src/data/content/attachment-repository.js'; +import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js'; +import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier.js'; +import { Language } from '../../../src/entities/content/language.js'; + +describe('AttachmentRepository', () => { + let attachmentRepository: AttachmentRepository; + let learningObjectRepository: LearningObjectRepository; + + beforeAll(async () => { + await setupTestApp(); + attachmentRepository = getAttachmentRepository(); + learningObjectRepository = getLearningObjectRepository(); + }); + + it('should return the requested attachment', async () => { + const id = new LearningObjectIdentifier('id02', Language.English, '1'); + const learningObject = await learningObjectRepository.findByIdentifier(id); + + const attachment = await attachmentRepository.findByMostRecentVersionOfLearningObjectAndName( + learningObject!, + Language.English, + 'attachment01' + ); + + expect(attachment).toBeTruthy(); + }); +}); diff --git a/backend/tests/data/content/learning-object-repository.test.ts b/backend/tests/data/content/learning-object-repository.test.ts new file mode 100644 index 00000000..12e14452 --- /dev/null +++ b/backend/tests/data/content/learning-object-repository.test.ts @@ -0,0 +1,72 @@ +import { beforeAll, describe, it, expect } from 'vitest'; +import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js'; +import { setupTestApp } from '../../setup-tests.js'; +import { getLearningObjectRepository } from '../../../src/data/repositories.js'; +import example from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.js'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity.js'; +import { expectToBeCorrectEntity } from '../../test-utils/expectations.js'; + +describe('LearningObjectRepository', () => { + let learningObjectRepository: LearningObjectRepository; + + let exampleLearningObject: LearningObject; + + beforeAll(async () => { + await setupTestApp(); + learningObjectRepository = getLearningObjectRepository(); + }); + + it('should be able to add a learning object to it without an error', async () => { + exampleLearningObject = example.createLearningObject(); + await learningObjectRepository.insert(exampleLearningObject); + }); + + it('should return the learning object when queried by id', async () => { + const result = await learningObjectRepository.findByIdentifier({ + hruid: exampleLearningObject.hruid, + language: exampleLearningObject.language, + version: exampleLearningObject.version, + }); + expect(result).toBeInstanceOf(LearningObject); + expectToBeCorrectEntity( + { + name: 'actual', + entity: result!, + }, + { + name: 'expected', + entity: exampleLearningObject, + } + ); + }); + + it('should return null when non-existing version is queried', async () => { + const result = await learningObjectRepository.findByIdentifier({ + hruid: exampleLearningObject.hruid, + language: exampleLearningObject.language, + version: 100, + }); + expect(result).toBe(null); + }); + + let newerExample: LearningObject; + + it('should allow a learning object with the same id except a different version to be added', async () => { + newerExample = example.createLearningObject(); + newerExample.version = 10; + newerExample.title += ' (nieuw)'; + await learningObjectRepository.save(newerExample); + }); + + it('should return the newest version of the learning object when queried by only hruid and language', async () => { + const result = await learningObjectRepository.findLatestByHruidAndLanguage(newerExample.hruid, newerExample.language); + expect(result).toBeInstanceOf(LearningObject); + expect(result?.version).toBe(10); + expect(result?.title).toContain('(nieuw)'); + }); + + it('should return null when queried by non-existing hruid or language', async () => { + const result = await learningObjectRepository.findLatestByHruidAndLanguage('something_that_does_not_exist', exampleLearningObject.language); + expect(result).toBe(null); + }); +}); diff --git a/backend/tests/data/content/learning-objects.test.ts b/backend/tests/data/content/learning-objects.test.ts new file mode 100644 index 00000000..51f9c98e --- /dev/null +++ b/backend/tests/data/content/learning-objects.test.ts @@ -0,0 +1,32 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository'; +import { getLearningObjectRepository } from '../../../src/data/repositories'; +import { setupTestApp } from '../../setup-tests'; +import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier'; +import { Language } from '../../../src/entities/content/language'; + +describe('LearningObjectRepository', () => { + let learningObjectRepository: LearningObjectRepository; + + beforeAll(async () => { + await setupTestApp(); + learningObjectRepository = getLearningObjectRepository(); + }); + + const id01 = new LearningObjectIdentifier('id01', Language.English, '1'); + const id02 = new LearningObjectIdentifier('test_id', Language.English, '1'); + + it('should return the learning object that matches identifier 1', async () => { + const learningObject = await learningObjectRepository.findByIdentifier(id01); + + expect(learningObject).toBeTruthy(); + expect(learningObject?.title).toBe('Undertow'); + expect(learningObject?.description).toBe('debute'); + }); + + it('should return nothing because the identifier does not exist in the database', async () => { + const learningObject = await learningObjectRepository.findByIdentifier(id02); + + expect(learningObject).toBeNull(); + }); +}); diff --git a/backend/tests/data/content/learning-path-repository.test.ts b/backend/tests/data/content/learning-path-repository.test.ts new file mode 100644 index 00000000..8dbff3c1 --- /dev/null +++ b/backend/tests/data/content/learning-path-repository.test.ts @@ -0,0 +1,66 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests.js'; +import { getLearningPathRepository } from '../../../src/data/repositories.js'; +import { LearningPathRepository } from '../../../src/data/content/learning-path-repository.js'; +import example from '../../test-assets/learning-paths/pn-werking-example.js'; +import { LearningPath } from '../../../src/entities/content/learning-path.entity.js'; +import { expectToBeCorrectEntity } from '../../test-utils/expectations.js'; +import { Language } from '../../../src/entities/content/language.js'; + +function expectToHaveFoundPrecisely(expected: LearningPath, result: LearningPath[]): void { + expect(result).toHaveProperty('length'); + expect(result.length).toBe(1); + expectToBeCorrectEntity({ entity: result[0]! }, { entity: expected }); +} + +function expectToHaveFoundNothing(result: LearningPath[]): void { + expect(result).toHaveProperty('length'); + expect(result.length).toBe(0); +} + +describe('LearningPathRepository', () => { + let learningPathRepo: LearningPathRepository; + + beforeAll(async () => { + await setupTestApp(); + learningPathRepo = getLearningPathRepository(); + }); + + let examplePath: LearningPath; + + it('should be able to add a learning path without throwing an error', async () => { + examplePath = example.createLearningPath(); + await learningPathRepo.insert(examplePath); + }); + + it('should return the added path when it is queried by hruid and language', async () => { + const result = await learningPathRepo.findByHruidAndLanguage(examplePath.hruid, examplePath.language); + expect(result).toBeInstanceOf(LearningPath); + expectToBeCorrectEntity({ entity: result! }, { entity: examplePath }); + }); + + it('should return null to a query on a non-existing hruid or language', async () => { + const result = await learningPathRepo.findByHruidAndLanguage('not_existing_hruid', examplePath.language); + expect(result).toBe(null); + }); + + it('should return the learning path when we search for a search term occurring in its title', async () => { + const result = await learningPathRepo.findByQueryStringAndLanguage(examplePath.title.slice(4, 9), examplePath.language); + expectToHaveFoundPrecisely(examplePath, result); + }); + + it('should return the learning path when we search for a search term occurring in its description', async () => { + const result = await learningPathRepo.findByQueryStringAndLanguage(examplePath.description.slice(8, 15), examplePath.language); + expectToHaveFoundPrecisely(examplePath, result); + }); + + it('should return null when we search for something not occurring in its title or description', async () => { + const result = await learningPathRepo.findByQueryStringAndLanguage('something not occurring in the path', examplePath.language); + expectToHaveFoundNothing(result); + }); + + it('should return null when we search for something occurring in its title, but another language', async () => { + const result = await learningPathRepo.findByQueryStringAndLanguage(examplePath.description.slice(1, 3), Language.Kalaallisut); + expectToHaveFoundNothing(result); + }); +}); diff --git a/backend/tests/data/content/learning-paths.test.ts b/backend/tests/data/content/learning-paths.test.ts new file mode 100644 index 00000000..01fd20e5 --- /dev/null +++ b/backend/tests/data/content/learning-paths.test.ts @@ -0,0 +1,28 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { getLearningPathRepository } from '../../../src/data/repositories'; +import { LearningPathRepository } from '../../../src/data/content/learning-path-repository'; +import { setupTestApp } from '../../setup-tests'; +import { Language } from '../../../src/entities/content/language'; + +describe('LearningPathRepository', () => { + let learningPathRepository: LearningPathRepository; + + beforeAll(async () => { + await setupTestApp(); + learningPathRepository = getLearningPathRepository(); + }); + + it('should return nothing because no match for hruid and language', async () => { + const learningPath = await learningPathRepository.findByHruidAndLanguage('test_id', Language.Dutch); + + expect(learningPath).toBeNull(); + }); + + it('should return requested learning path', async () => { + const learningPath = await learningPathRepository.findByHruidAndLanguage('id01', Language.English); + + expect(learningPath).toBeTruthy(); + expect(learningPath?.title).toBe('repertoire Tool'); + expect(learningPath?.description).toBe('all about Tool'); + }); +}); diff --git a/backend/tests/data/questions/answers.test.ts b/backend/tests/data/questions/answers.test.ts new file mode 100644 index 00000000..f15fed6a --- /dev/null +++ b/backend/tests/data/questions/answers.test.ts @@ -0,0 +1,66 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { AnswerRepository } from '../../../src/data/questions/answer-repository'; +import { getAnswerRepository, getQuestionRepository, getTeacherRepository } from '../../../src/data/repositories'; +import { QuestionRepository } from '../../../src/data/questions/question-repository'; +import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier'; +import { Language } from '../../../src/entities/content/language'; +import { TeacherRepository } from '../../../src/data/users/teacher-repository'; + +describe('AnswerRepository', () => { + let answerRepository: AnswerRepository; + let questionRepository: QuestionRepository; + let teacherRepository: TeacherRepository; + + beforeAll(async () => { + await setupTestApp(); + answerRepository = getAnswerRepository(); + questionRepository = getQuestionRepository(); + teacherRepository = getTeacherRepository(); + }); + + it('should find all answers to a question', async () => { + const id = new LearningObjectIdentifier('id05', Language.English, '1'); + const questions = await questionRepository.findAllQuestionsAboutLearningObject(id); + + const question = questions.filter((it) => it.sequenceNumber == 2)[0]; + + const answers = await answerRepository.findAllAnswersToQuestion(question); + + expect(answers).toBeTruthy(); + expect(answers).toHaveLength(2); + expect(answers[0].content).toBeOneOf(['answer', 'answer2']); + expect(answers[1].content).toBeOneOf(['answer', 'answer2']); + }); + + it('should create an answer to a question', async () => { + const teacher = await teacherRepository.findByUsername('FooFighters'); + const id = new LearningObjectIdentifier('id05', Language.English, '1'); + const questions = await questionRepository.findAllQuestionsAboutLearningObject(id); + + const question = questions[0]; + + await answerRepository.createAnswer({ + toQuestion: question, + author: teacher!, + content: 'created answer', + }); + + const answers = await answerRepository.findAllAnswersToQuestion(question); + + expect(answers).toBeTruthy(); + expect(answers).toHaveLength(1); + expect(answers[0].content).toBe('created answer'); + }); + + it('should not find a removed answer', async () => { + const id = new LearningObjectIdentifier('id04', Language.English, '1'); + const questions = await questionRepository.findAllQuestionsAboutLearningObject(id); + + await answerRepository.removeAnswerByQuestionAndSequenceNumber(questions[0], 1); + + const emptyList = await answerRepository.findAllAnswersToQuestion(questions[0]); + + expect(emptyList).toHaveLength(0); + }); +}); diff --git a/backend/tests/data/questions/questions.test.ts b/backend/tests/data/questions/questions.test.ts new file mode 100644 index 00000000..1a1cb034 --- /dev/null +++ b/backend/tests/data/questions/questions.test.ts @@ -0,0 +1,52 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { QuestionRepository } from '../../../src/data/questions/question-repository'; +import { getLearningObjectRepository, getQuestionRepository, getStudentRepository } from '../../../src/data/repositories'; +import { StudentRepository } from '../../../src/data/users/student-repository'; +import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository'; +import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier'; +import { Language } from '../../../src/entities/content/language'; + +describe('QuestionRepository', () => { + let questionRepository: QuestionRepository; + let studentRepository: StudentRepository; + let learningObjectRepository: LearningObjectRepository; + + beforeAll(async () => { + await setupTestApp(); + questionRepository = getQuestionRepository(); + studentRepository = getStudentRepository(); + learningObjectRepository = getLearningObjectRepository(); + }); + + it('should return all questions part of the given learning object', async () => { + const id = new LearningObjectIdentifier('id05', Language.English, '1'); + const questions = await questionRepository.findAllQuestionsAboutLearningObject(id); + + expect(questions).toBeTruthy(); + expect(questions).toHaveLength(2); + }); + + it('should create new question', async () => { + const id = new LearningObjectIdentifier('id03', Language.English, '1'); + const student = await studentRepository.findByUsername('Noordkaap'); + await questionRepository.createQuestion({ + loId: id, + author: student!, + content: 'question?', + }); + const question = await questionRepository.findAllQuestionsAboutLearningObject(id); + + expect(question).toBeTruthy(); + expect(question).toHaveLength(1); + }); + + it('should not find removed question', async () => { + const id = new LearningObjectIdentifier('id04', Language.English, '1'); + await questionRepository.removeQuestionByLearningObjectAndSequenceNumber(id, 1); + + const question = await questionRepository.findAllQuestionsAboutLearningObject(id); + + expect(question).toHaveLength(0); + }); +}); diff --git a/backend/tests/data/users.test.ts b/backend/tests/data/users/students.test.ts similarity index 57% rename from backend/tests/data/users.test.ts rename to backend/tests/data/users/students.test.ts index 87149050..78800e1f 100644 --- a/backend/tests/data/users.test.ts +++ b/backend/tests/data/users/students.test.ts @@ -1,8 +1,8 @@ -import { setupTestApp } from '../setup-tests.js'; -import { Student } from '../../src/entities/users/student.entity.js'; +import { setupTestApp } from '../../setup-tests.js'; +import { Student } from '../../../src/entities/users/student.entity.js'; import { describe, it, expect, beforeAll } from 'vitest'; -import { StudentRepository } from '../../src/data/users/student-repository.js'; -import { getStudentRepository } from '../../src/data/repositories.js'; +import { StudentRepository } from '../../../src/data/users/student-repository.js'; +import { getStudentRepository } from '../../../src/data/repositories.js'; const username = 'teststudent'; const firstName = 'John'; @@ -15,6 +15,20 @@ describe('StudentRepository', () => { studentRepository = getStudentRepository(); }); + it('should not return a student because username does not exist', async () => { + const student = await studentRepository.findByUsername('test'); + + expect(student).toBeNull(); + }); + + it('should return student from the datbase', async () => { + const student = await studentRepository.findByUsername('Noordkaap'); + + expect(student).toBeTruthy(); + expect(student?.firstName).toBe('Stijn'); + expect(student?.lastName).toBe('Meuris'); + }); + it('should return the queried student after he was added', async () => { await studentRepository.insert(new Student(username, firstName, lastName)); diff --git a/backend/tests/data/users/teachers.test.ts b/backend/tests/data/users/teachers.test.ts new file mode 100644 index 00000000..0bd014a6 --- /dev/null +++ b/backend/tests/data/users/teachers.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { TeacherRepository } from '../../../src/data/users/teacher-repository'; +import { setupTestApp } from '../../setup-tests'; +import { getTeacherRepository } from '../../../src/data/repositories'; +import { Teacher } from '../../../src/entities/users/teacher.entity'; + +const username = 'testteacher'; +const firstName = 'John'; +const lastName = 'Doe'; +describe('TeacherRepository', () => { + let teacherRepository: TeacherRepository; + + beforeAll(async () => { + await setupTestApp(); + teacherRepository = getTeacherRepository(); + }); + + it('should not return a teacher because username does not exist', async () => { + const teacher = await teacherRepository.findByUsername('test'); + + expect(teacher).toBeNull(); + }); + + it('should return teacher from the datbase', async () => { + const teacher = await teacherRepository.findByUsername('FooFighters'); + + expect(teacher).toBeTruthy(); + expect(teacher?.firstName).toBe('Dave'); + expect(teacher?.lastName).toBe('Grohl'); + }); + + it('should return the queried teacher after he was added', async () => { + await teacherRepository.insert(new Teacher(username, firstName, lastName)); + + const retrievedTeacher = await teacherRepository.findByUsername(username); + expect(retrievedTeacher).toBeTruthy(); + expect(retrievedTeacher?.firstName).toBe(firstName); + expect(retrievedTeacher?.lastName).toBe(lastName); + }); + + it('should no longer return the queried teacher after he was removed again', async () => { + await teacherRepository.deleteByUsername('ZesdeMetaal'); + + const retrievedTeacher = await teacherRepository.findByUsername('ZesdeMetaal'); + expect(retrievedTeacher).toBeNull(); + }); +}); diff --git a/backend/tests/service/learning-objects.test.ts b/backend/tests/service/learning-objects.test.ts new file mode 100644 index 00000000..130c237e --- /dev/null +++ b/backend/tests/service/learning-objects.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from 'vitest'; +import { LearningObjectMetadata, LearningPath } from '../../src/interfaces/learningPath'; +import { fetchWithLogging } from '../../src/util/apiHelper'; +import { getLearningObjectById, getLearningObjectsFromPath } from '../../src/services/learningObjects'; +import { fetchLearningPaths } from '../../src/services/learningPaths'; + +// Mock API functions +vi.mock('../../src/util/apiHelper', () => ({ + fetchWithLogging: vi.fn(), +})); + +vi.mock('../../src/services/learningPaths', () => ({ + fetchLearningPaths: vi.fn(), +})); + +describe('getLearningObjectById', () => { + const hruid = 'test-object'; + const language = 'en'; + const mockMetadata: LearningObjectMetadata = { + hruid, + _id: '123', + uuid: 'uuid-123', + version: 1, + title: 'Test Object', + language, + difficulty: 5, + estimated_time: 120, + available: true, + teacher_exclusive: false, + educational_goals: [{ source: 'source', id: 'id' }], + keywords: ['robotics'], + description: 'A test object', + target_ages: [10, 12], + content_type: 'markdown', + content_location: '', + }; + + it('✅ Should return a filtered learning object when API provides data', async () => { + vi.mocked(fetchWithLogging).mockResolvedValueOnce(mockMetadata); + + const result = await getLearningObjectById(hruid, language); + + expect(result).toEqual({ + key: hruid, + _id: '123', + uuid: 'uuid-123', + version: 1, + title: 'Test Object', + htmlUrl: expect.stringContaining('/learningObject/getRaw?hruid=test-object&language=en'), + language, + difficulty: 5, + estimatedTime: 120, + available: true, + teacherExclusive: false, + educationalGoals: [{ source: 'source', id: 'id' }], + keywords: ['robotics'], + description: 'A test object', + targetAges: [10, 12], + contentType: 'markdown', + contentLocation: '', + }); + }); + + it('⚠️ Should return null if API returns no metadata', async () => { + vi.mocked(fetchWithLogging).mockResolvedValueOnce(null); + const result = await getLearningObjectById(hruid, language); + expect(result).toBeNull(); + }); +}); + +describe('getLearningObjectsFromPath', () => { + const hruid = 'test-path'; + const language = 'en'; + + it('✅ Should not give error or warning', async () => { + const mockPathResponse: LearningPath[] = [ + { + _id: 'path-1', + hruid, + language, + title: 'Test Path', + description: '', + num_nodes: 1, + num_nodes_left: 0, + nodes: [], + keywords: '', + target_ages: [], + min_age: 10, + max_age: 12, + __order: 1, + }, + ]; + + vi.mocked(fetchLearningPaths).mockResolvedValueOnce({ + success: true, + source: 'Test Source', + data: mockPathResponse, + }); + + const result = await getLearningObjectsFromPath(hruid, language); + expect(result).toEqual([]); + }); + + it('⚠️ Should give a warning', async () => { + vi.mocked(fetchLearningPaths).mockResolvedValueOnce({ success: false, source: 'Test Source', data: [] }); + + const result = await getLearningObjectsFromPath(hruid, language); + expect(result).toEqual([]); + }); + + it('❌ Should give an error', async () => { + vi.mocked(fetchLearningPaths).mockRejectedValueOnce(new Error('API Error')); + + const result = await getLearningObjectsFromPath(hruid, language); + expect(result).toEqual([]); + }); +}); diff --git a/backend/tests/service/learning-paths.test.ts b/backend/tests/service/learning-paths.test.ts new file mode 100644 index 00000000..c002dbac --- /dev/null +++ b/backend/tests/service/learning-paths.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi } from 'vitest'; +import { fetchLearningPaths, searchLearningPaths } from '../../src/services/learningPaths'; +import { fetchWithLogging } from '../../src/util/apiHelper'; +import { LearningPathResponse } from '../../src/interfaces/learningPath'; + +// Mock the fetchWithLogging module using vi +vi.mock('../../src/util/apiHelper', () => ({ + fetchWithLogging: vi.fn(), +})); + +describe('fetchLearningPaths', () => { + // Mock data and response + const mockHruids = ['pn_werking', 'art1']; + const language = 'en'; + const source = 'Test Source'; + const mockResponse = [{ title: 'Test Path', hruids: mockHruids }]; + + it('✅ Should return a successful response when HRUIDs are provided', async () => { + // Mock the function to return mockResponse + vi.mocked(fetchWithLogging).mockResolvedValue(mockResponse); + + const result: LearningPathResponse = await fetchLearningPaths(mockHruids, language, source); + + expect(result.success).toBe(true); + expect(result.data).toEqual(mockResponse); + expect(result.source).toBe(source); + }); + + it('⚠️ Should return an error when no HRUIDs are provided', async () => { + vi.mocked(fetchWithLogging).mockResolvedValue(mockResponse); + + const result: LearningPathResponse = await fetchLearningPaths([], language, source); + + expect(result.success).toBe(false); + expect(result.data).toBeNull(); + expect(result.message).toBe(`No HRUIDs provided for ${source}.`); + }); + + it('⚠️ Should return a failure response when no learning paths are found', async () => { + // Mock fetchWithLogging to return an empty array + vi.mocked(fetchWithLogging).mockResolvedValue([]); + + const result: LearningPathResponse = await fetchLearningPaths(mockHruids, language, source); + + expect(result.success).toBe(false); + expect(result.data).toEqual([]); + expect(result.message).toBe(`No learning paths found for ${source}.`); + }); +}); + +describe('searchLearningPaths', () => { + const query = + 'https://dwengo.org/backend/api/learningPath/getPathsFromIdList?pathIdList=%7B%22hruids%22:%5B%22pn_werking%22,%22un_artificiele_intelligentie%22%5D%7D&language=nl'; + const language = 'nl'; + + it('✅ Should return search results when API responds with data', async () => { + const mockResults = [ + { + _id: '67b4488c9dadb305c4104618', + language: 'nl', + hruid: 'pn_werking', + title: 'Werken met notebooks', + description: 'Een korte inleiding tot Python notebooks. Hoe ga je gemakkelijk en efficiënt met de notebooks aan de slag?', + num_nodes: 0, + num_nodes_left: 0, + nodes: [], + keywords: 'Python KIKS Wiskunde STEM AI', + target_ages: [14, 15, 16, 17, 18], + min_age: 14, + max_age: 18, + __order: 0, + }, + ]; + + // Mock fetchWithLogging to return search results + vi.mocked(fetchWithLogging).mockResolvedValue(mockResults); + + const result = await searchLearningPaths(query, language); + + expect(result).toEqual(mockResults); + }); + + it('⚠️ Should return an empty array when API returns no results', async () => { + vi.mocked(fetchWithLogging).mockResolvedValue([]); + + const result = await searchLearningPaths(query, language); + + expect(result).toEqual([]); + }); +}); diff --git a/backend/tests/services/learning-objects/database-learning-object-provider.test.ts b/backend/tests/services/learning-objects/database-learning-object-provider.test.ts new file mode 100644 index 00000000..692a72de --- /dev/null +++ b/backend/tests/services/learning-objects/database-learning-object-provider.test.ts @@ -0,0 +1,108 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { getLearningObjectRepository, getLearningPathRepository } from '../../../src/data/repositories'; +import example from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity'; +import databaseLearningObjectProvider from '../../../src/services/learning-objects/database-learning-object-provider'; +import { expectToBeCorrectFilteredLearningObject } from '../../test-utils/expectations'; +import { FilteredLearningObject } from '../../../src/interfaces/learning-content'; +import { Language } from '../../../src/entities/content/language'; +import learningObjectExample from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; +import learningPathExample from '../../test-assets/learning-paths/pn-werking-example'; +import { LearningPath } from '../../../src/entities/content/learning-path.entity'; + +async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { + const learningObjectRepo = getLearningObjectRepository(); + const learningPathRepo = getLearningPathRepository(); + const learningObject = learningObjectExample.createLearningObject(); + const learningPath = learningPathExample.createLearningPath(); + await learningObjectRepo.save(learningObject); + await learningPathRepo.save(learningPath); + return { learningObject, learningPath }; +} + +const EXPECTED_TITLE_FROM_DWENGO_LEARNING_OBJECT = 'Notebook opslaan'; + +describe('DatabaseLearningObjectProvider', () => { + let exampleLearningObject: LearningObject; + let exampleLearningPath: LearningPath; + + beforeAll(async () => { + await setupTestApp(); + const exampleData = await initExampleData(); + exampleLearningObject = exampleData.learningObject; + exampleLearningPath = exampleData.learningPath; + }); + describe('getLearningObjectById', () => { + it('should return the learning object when it is queried by its id', async () => { + const result: FilteredLearningObject | null = await databaseLearningObjectProvider.getLearningObjectById(exampleLearningObject); + expect(result).toBeTruthy(); + expectToBeCorrectFilteredLearningObject(result!, exampleLearningObject); + }); + + it('should return the learning object when it is queried by only hruid and language (but not version)', async () => { + const result: FilteredLearningObject | null = await databaseLearningObjectProvider.getLearningObjectById({ + hruid: exampleLearningObject.hruid, + language: exampleLearningObject.language, + }); + expect(result).toBeTruthy(); + expectToBeCorrectFilteredLearningObject(result!, exampleLearningObject); + }); + + it('should return null when queried with an id that does not exist', async () => { + const result: FilteredLearningObject | null = await databaseLearningObjectProvider.getLearningObjectById({ + hruid: 'non_existing_hruid', + language: Language.Dutch, + }); + expect(result).toBeNull(); + }); + }); + describe('getLearningObjectHTML', () => { + it('should return the correct rendering of the learning object', async () => { + const result = await databaseLearningObjectProvider.getLearningObjectHTML(exampleLearningObject); + expect(result).toEqual(example.getHTMLRendering()); + }); + it('should return null for a non-existing learning object', async () => { + const result = await databaseLearningObjectProvider.getLearningObjectHTML({ + hruid: 'non_existing_hruid', + language: Language.Dutch, + }); + expect(result).toBeNull(); + }); + }); + describe('getLearningObjectIdsFromPath', () => { + it('should return all learning object IDs from a path', async () => { + const result = await databaseLearningObjectProvider.getLearningObjectIdsFromPath(exampleLearningPath); + expect(new Set(result)).toEqual(new Set(exampleLearningPath.nodes.map((it) => it.learningObjectHruid))); + }); + it('should throw an error if queried with a path identifier for which there is no learning path', async () => { + await expect( + (async () => { + await databaseLearningObjectProvider.getLearningObjectIdsFromPath({ + hruid: 'non_existing_hruid', + language: Language.Dutch, + }); + })() + ).rejects.toThrowError(); + }); + }); + describe('getLearningObjectsFromPath', () => { + it('should correctly return all learning objects which are on the path, even those who are not in the database', async () => { + const result = await databaseLearningObjectProvider.getLearningObjectsFromPath(exampleLearningPath); + expect(result.length).toBe(exampleLearningPath.nodes.length); + expect(new Set(result.map((it) => it.key))).toEqual(new Set(exampleLearningPath.nodes.map((it) => it.learningObjectHruid))); + + expect(result.map((it) => it.title)).toContainEqual(EXPECTED_TITLE_FROM_DWENGO_LEARNING_OBJECT); + }); + it('should throw an error if queried with a path identifier for which there is no learning path', async () => { + await expect( + (async () => { + await databaseLearningObjectProvider.getLearningObjectsFromPath({ + hruid: 'non_existing_hruid', + language: Language.Dutch, + }); + })() + ).rejects.toThrowError(); + }); + }); +}); diff --git a/backend/tests/services/learning-objects/learning-object-service.test.ts b/backend/tests/services/learning-objects/learning-object-service.test.ts new file mode 100644 index 00000000..d80262df --- /dev/null +++ b/backend/tests/services/learning-objects/learning-object-service.test.ts @@ -0,0 +1,126 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity'; +import { getLearningObjectRepository, getLearningPathRepository } from '../../../src/data/repositories'; +import learningObjectExample from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; +import learningObjectService from '../../../src/services/learning-objects/learning-object-service'; +import { LearningObjectIdentifier, LearningPathIdentifier } from '../../../src/interfaces/learning-content'; +import { Language } from '../../../src/entities/content/language'; +import { EnvVars, getEnvVar } from '../../../src/util/envvars'; +import { LearningPath } from '../../../src/entities/content/learning-path.entity'; +import learningPathExample from '../../test-assets/learning-paths/pn-werking-example'; + +const EXPECTED_DWENGO_LEARNING_OBJECT_TITLE = 'Werken met notebooks'; +const DWENGO_TEST_LEARNING_OBJECT_ID: LearningObjectIdentifier = { + hruid: 'pn_werkingnotebooks', + language: Language.Dutch, + version: 3, +}; + +const DWENGO_TEST_LEARNING_PATH_ID: LearningPathIdentifier = { + hruid: 'pn_werking', + language: Language.Dutch, +}; +const DWENGO_TEST_LEARNING_PATH_HRUIDS = new Set(['pn_werkingnotebooks', 'pn_werkingnotebooks2', 'pn_werkingnotebooks3']); + +async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { + const learningObjectRepo = getLearningObjectRepository(); + const learningPathRepo = getLearningPathRepository(); + const learningObject = learningObjectExample.createLearningObject(); + const learningPath = learningPathExample.createLearningPath(); + await learningObjectRepo.save(learningObject); + await learningPathRepo.save(learningPath); + return { learningObject, learningPath }; +} + +describe('LearningObjectService', () => { + let exampleLearningObject: LearningObject; + let exampleLearningPath: LearningPath; + + beforeAll(async () => { + await setupTestApp(); + const exampleData = await initExampleData(); + exampleLearningObject = exampleData.learningObject; + exampleLearningPath = exampleData.learningPath; + }); + + describe('getLearningObjectById', () => { + it('returns the learning object from the Dwengo API if it does not have the user content prefix', async () => { + const result = await learningObjectService.getLearningObjectById(DWENGO_TEST_LEARNING_OBJECT_ID); + expect(result).not.toBeNull(); + expect(result?.title).toBe(EXPECTED_DWENGO_LEARNING_OBJECT_TITLE); + }); + it('returns the learning object from the database if it does have the user content prefix', async () => { + const result = await learningObjectService.getLearningObjectById(exampleLearningObject); + expect(result).not.toBeNull(); + expect(result?.title).toBe(exampleLearningObject.title); + }); + it('returns null if the hruid does not have the user content prefix and does not exist in the Dwengo repo', async () => { + const result = await learningObjectService.getLearningObjectById({ + hruid: 'non-existing', + language: Language.Dutch, + }); + expect(result).toBeNull(); + }); + }); + + describe('getLearningObjectHTML', () => { + it('returns the expected HTML when queried with the identifier of a learning object saved in the database', async () => { + const result = await learningObjectService.getLearningObjectHTML(exampleLearningObject); + expect(result).not.toBeNull(); + expect(result).toEqual(learningObjectExample.getHTMLRendering()); + }); + it( + 'returns the same HTML as the Dwengo API when queried with the identifier of a learning object that does ' + + 'not start with the user content prefix', + async () => { + const result = await learningObjectService.getLearningObjectHTML(DWENGO_TEST_LEARNING_OBJECT_ID); + expect(result).not.toBeNull(); + + const responseFromDwengoApi = await fetch( + getEnvVar(EnvVars.LearningContentRepoApiBaseUrl) + + `/learningObject/getRaw?hruid=${DWENGO_TEST_LEARNING_OBJECT_ID.hruid}&language=${DWENGO_TEST_LEARNING_OBJECT_ID.language}&version=${DWENGO_TEST_LEARNING_OBJECT_ID.version}` + ); + const responseHtml = await responseFromDwengoApi.text(); + expect(result).toEqual(responseHtml); + } + ); + it('returns null when queried with a non-existing identifier', async () => { + const result = await learningObjectService.getLearningObjectHTML({ + hruid: 'non_existing_hruid', + language: Language.Dutch, + }); + expect(result).toBeNull(); + }); + }); + + describe('getLearningObjectsFromPath', () => { + it('returns all learning objects when a learning path in the database is queried', async () => { + const result = await learningObjectService.getLearningObjectsFromPath(exampleLearningPath); + expect(result.map((it) => it.key)).toEqual(exampleLearningPath.nodes.map((it) => it.learningObjectHruid)); + }); + it('also returns all learning objects when a learning path from the Dwengo API is queried', async () => { + const result = await learningObjectService.getLearningObjectsFromPath(DWENGO_TEST_LEARNING_PATH_ID); + expect(new Set(result.map((it) => it.key))).toEqual(DWENGO_TEST_LEARNING_PATH_HRUIDS); + }); + it('returns an empty list when queried with a non-existing learning path id', async () => { + const result = await learningObjectService.getLearningObjectsFromPath({ hruid: 'non_existing', language: Language.Dutch }); + expect(result).toEqual([]); + }); + }); + + describe('getLearningObjectIdsFromPath', () => { + it('returns all learning objects when a learning path in the database is queried', async () => { + const result = await learningObjectService.getLearningObjectIdsFromPath(exampleLearningPath); + expect(result).toEqual(exampleLearningPath.nodes.map((it) => it.learningObjectHruid)); + }); + it('also returns all learning object hruids when a learning path from the Dwengo API is queried', async () => { + const result = await learningObjectService.getLearningObjectIdsFromPath(DWENGO_TEST_LEARNING_PATH_ID); + expect(new Set(result)).toEqual(DWENGO_TEST_LEARNING_PATH_HRUIDS); + }); + it('returns an empty list when queried with a non-existing learning path id', async () => { + const result = await learningObjectService.getLearningObjectIdsFromPath({ hruid: 'non_existing', language: Language.Dutch }); + expect(result).toEqual([]); + }); + }); +}); diff --git a/backend/tests/services/learning-objects/processing/processing-service.test.ts b/backend/tests/services/learning-objects/processing/processing-service.test.ts new file mode 100644 index 00000000..27714317 --- /dev/null +++ b/backend/tests/services/learning-objects/processing/processing-service.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import mdExample from '../../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; +import multipleChoiceExample from '../../../test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example'; +import essayExample from '../../../test-assets/learning-objects/test-essay/test-essay-example'; +import processingService from '../../../../src/services/learning-objects/processing/processing-service'; + +describe('ProcessingService', () => { + it('renders a markdown learning object correctly', async () => { + const markdownLearningObject = mdExample.createLearningObject(); + const result = await processingService.render(markdownLearningObject); + expect(result).toEqual(mdExample.getHTMLRendering()); + }); + + it('renders a multiple choice question correctly', async () => { + const multipleChoiceLearningObject = multipleChoiceExample.createLearningObject(); + const result = await processingService.render(multipleChoiceLearningObject); + expect(result).toEqual(multipleChoiceExample.getHTMLRendering()); + }); + + it('renders an essay question correctly', async () => { + const essayLearningObject = essayExample.createLearningObject(); + const result = await processingService.render(essayLearningObject); + expect(result).toEqual(essayExample.getHTMLRendering()); + }); +}); diff --git a/backend/tests/services/learning-path/database-learning-path-provider.test.ts b/backend/tests/services/learning-path/database-learning-path-provider.test.ts new file mode 100644 index 00000000..7c7ecdae --- /dev/null +++ b/backend/tests/services/learning-path/database-learning-path-provider.test.ts @@ -0,0 +1,220 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity.js'; +import { setupTestApp } from '../../setup-tests.js'; +import { LearningPath } from '../../../src/entities/content/learning-path.entity.js'; +import { + getLearningObjectRepository, + getLearningPathRepository, + getStudentRepository, + getSubmissionRepository, +} from '../../../src/data/repositories.js'; +import learningObjectExample from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.js'; +import learningPathExample from '../../test-assets/learning-paths/pn-werking-example.js'; +import databaseLearningPathProvider from '../../../src/services/learning-paths/database-learning-path-provider.js'; +import { expectToBeCorrectLearningPath } from '../../test-utils/expectations.js'; +import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js'; +import learningObjectService from '../../../src/services/learning-objects/learning-object-service.js'; +import { Language } from '../../../src/entities/content/language.js'; +import { + ConditionTestLearningPathAndLearningObjects, + createConditionTestLearningPathAndLearningObjects, +} from '../../test-assets/learning-paths/test-conditions-example.js'; +import { Student } from '../../../src/entities/users/student.entity.js'; +import { LearningObjectNode, LearningPathResponse } from '../../../src/interfaces/learning-content.js'; + +async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { + const learningObjectRepo = getLearningObjectRepository(); + const learningPathRepo = getLearningPathRepository(); + const learningObject = learningObjectExample.createLearningObject(); + const learningPath = learningPathExample.createLearningPath(); + await learningObjectRepo.save(learningObject); + await learningPathRepo.save(learningPath); + return { learningObject, learningPath }; +} + +async function initPersonalizationTestData(): Promise<{ + learningContent: ConditionTestLearningPathAndLearningObjects; + studentA: Student; + studentB: Student; +}> { + const studentRepo = getStudentRepository(); + const submissionRepo = getSubmissionRepository(); + const learningPathRepo = getLearningPathRepository(); + const learningObjectRepo = getLearningObjectRepository(); + const learningContent = createConditionTestLearningPathAndLearningObjects(); + await learningObjectRepo.save(learningContent.branchingObject); + await learningObjectRepo.save(learningContent.finalObject); + await learningObjectRepo.save(learningContent.extraExerciseObject); + await learningPathRepo.save(learningContent.learningPath); + + console.log(await getSubmissionRepository().findAll({})); + + const studentA = studentRepo.create({ + username: 'student_a', + firstName: 'Aron', + lastName: 'Student', + }); + await studentRepo.save(studentA); + const submissionA = submissionRepo.create({ + learningObjectHruid: learningContent.branchingObject.hruid, + learningObjectLanguage: learningContent.branchingObject.language, + learningObjectVersion: learningContent.branchingObject.version, + submissionNumber: 0, + submitter: studentA, + submissionTime: new Date(), + content: '[0]', + }); + await submissionRepo.save(submissionA); + + const studentB = studentRepo.create({ + username: 'student_b', + firstName: 'Bill', + lastName: 'Student', + }); + await studentRepo.save(studentB); + const submissionB = submissionRepo.create({ + learningObjectHruid: learningContent.branchingObject.hruid, + learningObjectLanguage: learningContent.branchingObject.language, + learningObjectVersion: learningContent.branchingObject.version, + submissionNumber: 1, + submitter: studentB, + submissionTime: new Date(), + content: '[1]', + }); + await submissionRepo.save(submissionB); + + return { + learningContent: learningContent, + studentA: studentA, + studentB: studentB, + }; +} + +function expectBranchingObjectNode( + result: LearningPathResponse, + persTestData: { + learningContent: ConditionTestLearningPathAndLearningObjects; + studentA: Student; + studentB: Student; + } +): LearningObjectNode { + const branchingObjectMatches = result.data![0].nodes.filter( + (it) => it.learningobject_hruid === persTestData.learningContent.branchingObject.hruid + ); + expect(branchingObjectMatches.length).toBe(1); + return branchingObjectMatches[0]; +} + +describe('DatabaseLearningPathProvider', () => { + let learningObjectRepo: LearningObjectRepository; + let example: { learningObject: LearningObject; learningPath: LearningPath }; + let persTestData: { learningContent: ConditionTestLearningPathAndLearningObjects; studentA: Student; studentB: Student }; + + beforeAll(async () => { + await setupTestApp(); + example = await initExampleData(); + persTestData = await initPersonalizationTestData(); + learningObjectRepo = getLearningObjectRepository(); + }); + + describe('fetchLearningPaths', () => { + it('returns the learning path correctly', async () => { + const result = await databaseLearningPathProvider.fetchLearningPaths( + [example.learningPath.hruid], + example.learningPath.language, + 'the source' + ); + expect(result.success).toBe(true); + expect(result.data?.length).toBe(1); + + const learningObjectsOnPath = ( + await Promise.all( + example.learningPath.nodes.map((node) => + learningObjectService.getLearningObjectById({ + hruid: node.learningObjectHruid, + version: node.version, + language: node.language, + }) + ) + ) + ).filter((it) => it !== null); + + expectToBeCorrectLearningPath(result.data![0], example.learningPath, learningObjectsOnPath); + }); + it('returns the correct personalized learning path', async () => { + // For student A: + let result = await databaseLearningPathProvider.fetchLearningPaths( + [persTestData.learningContent.learningPath.hruid], + persTestData.learningContent.learningPath.language, + 'the source', + { type: 'student', student: persTestData.studentA } + ); + expect(result.success).toBeTruthy(); + expect(result.data?.length).toBe(1); + + // There should be exactly one branching object + let branchingObject = expectBranchingObjectNode(result, persTestData); + + expect(branchingObject.transitions.filter((it) => it.next.hruid === persTestData.learningContent.finalObject.hruid).length).toBe(0); // StudentA picked the first option, therefore, there should be no direct path to the final object. + expect(branchingObject.transitions.filter((it) => it.next.hruid === persTestData.learningContent.extraExerciseObject.hruid).length).toBe( + 1 + ); // There should however be a path to the extra exercise object. + + // For student B: + result = await databaseLearningPathProvider.fetchLearningPaths( + [persTestData.learningContent.learningPath.hruid], + persTestData.learningContent.learningPath.language, + 'the source', + { type: 'student', student: persTestData.studentB } + ); + expect(result.success).toBeTruthy(); + expect(result.data?.length).toBe(1); + + // There should still be exactly one branching object + branchingObject = expectBranchingObjectNode(result, persTestData); + + // However, now the student picks the other option. + expect(branchingObject.transitions.filter((it) => it.next.hruid === persTestData.learningContent.finalObject.hruid).length).toBe(1); // StudentB picked the second option, therefore, there should be a direct path to the final object. + expect(branchingObject.transitions.filter((it) => it.next.hruid === persTestData.learningContent.extraExerciseObject.hruid).length).toBe( + 0 + ); // There should not be a path anymore to the extra exercise object. + }); + it('returns a non-successful response if a non-existing learning path is queried', async () => { + const result = await databaseLearningPathProvider.fetchLearningPaths( + [example.learningPath.hruid], + Language.Abkhazian, // Wrong language + 'the source' + ); + + expect(result.success).toBe(false); + }); + }); + + describe('searchLearningPaths', () => { + it('returns the correct learning path when queried with a substring of its title', async () => { + const result = await databaseLearningPathProvider.searchLearningPaths( + example.learningPath.title.substring(2, 6), + example.learningPath.language + ); + expect(result.length).toBe(1); + expect(result[0].title).toBe(example.learningPath.title); + expect(result[0].description).toBe(example.learningPath.description); + }); + it('returns the correct learning path when queried with a substring of the description', async () => { + const result = await databaseLearningPathProvider.searchLearningPaths( + example.learningPath.description.substring(5, 12), + example.learningPath.language + ); + expect(result.length).toBe(1); + expect(result[0].title).toBe(example.learningPath.title); + expect(result[0].description).toBe(example.learningPath.description); + }); + it('returns an empty result when queried with a text which is not a substring of the title or the description of a learning path', async () => { + const result = await databaseLearningPathProvider.searchLearningPaths( + 'substring which does not occur in the title or the description of a learning object', + example.learningPath.language + ); + expect(result.length).toBe(0); + }); + }); +}); diff --git a/backend/tests/services/learning-path/learning-path-service.test.ts b/backend/tests/services/learning-path/learning-path-service.test.ts new file mode 100644 index 00000000..2a8906df --- /dev/null +++ b/backend/tests/services/learning-path/learning-path-service.test.ts @@ -0,0 +1,81 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity'; +import { LearningPath } from '../../../src/entities/content/learning-path.entity'; +import { getLearningObjectRepository, getLearningPathRepository } from '../../../src/data/repositories'; +import learningObjectExample from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; +import learningPathExample from '../../test-assets/learning-paths/pn-werking-example'; +import { Language } from '../../../src/entities/content/language'; +import learningPathService from '../../../src/services/learning-paths/learning-path-service'; + +async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { + const learningObjectRepo = getLearningObjectRepository(); + const learningPathRepo = getLearningPathRepository(); + const learningObject = learningObjectExample.createLearningObject(); + const learningPath = learningPathExample.createLearningPath(); + await learningObjectRepo.save(learningObject); + await learningPathRepo.save(learningPath); + return { learningObject, learningPath }; +} + +const TEST_DWENGO_LEARNING_PATH_HRUID = 'pn_werking'; +const TEST_DWENGO_LEARNING_PATH_TITLE = 'Werken met notebooks'; +const TEST_DWENGO_EXCLUSIVE_LEARNING_PATH_SEARCH_QUERY = 'Microscopie'; +const TEST_SEARCH_QUERY_EXPECTING_NO_MATCHES = 'su$m8f9usf89ud { + let example: { learningObject: LearningObject; learningPath: LearningPath }; + beforeAll(async () => { + await setupTestApp(); + example = await initExampleData(); + }); + describe('fetchLearningPaths', () => { + it('should return learning paths both from the database and from the Dwengo API', async () => { + const result = await learningPathService.fetchLearningPaths( + [example.learningPath.hruid, TEST_DWENGO_LEARNING_PATH_HRUID], + example.learningPath.language, + 'the source' + ); + expect(result.success).toBeTruthy(); + expect(result.data?.filter((it) => it.hruid === TEST_DWENGO_LEARNING_PATH_HRUID).length).not.toBe(0); + expect(result.data?.filter((it) => it.hruid === example.learningPath.hruid).length).not.toBe(0); + expect(result.data?.filter((it) => it.hruid === TEST_DWENGO_LEARNING_PATH_HRUID)[0].title).toEqual(TEST_DWENGO_LEARNING_PATH_TITLE); + expect(result.data?.filter((it) => it.hruid === example.learningPath.hruid)[0].title).toEqual(example.learningPath.title); + }); + it('should include both the learning objects from the Dwengo API and learning objects from the database in its response', async () => { + const result = await learningPathService.fetchLearningPaths([example.learningPath.hruid], example.learningPath.language, 'the source'); + expect(result.success).toBeTruthy(); + expect(result.data?.length).toBe(1); + + // Should include all the nodes, even those pointing to foreign learning objects. + expect([...result.data![0].nodes.map((it) => it.learningobject_hruid)].sort()).toEqual( + example.learningPath.nodes.map((it) => it.learningObjectHruid).sort() + ); + }); + }); + describe('searchLearningPath', () => { + it('should include both the learning paths from the Dwengo API and those from the database in its response', async () => { + // This matches the learning object in the database, but definitely also some learning objects in the Dwengo API. + const result = await learningPathService.searchLearningPaths(example.learningPath.title.substring(2, 3), example.learningPath.language); + + // Should find the one from the database + expect(result.filter((it) => it.hruid === example.learningPath.hruid && it.title === example.learningPath.title).length).toBe(1); + + // But should not only find that one. + expect(result.length).not.toBeLessThan(2); + }); + it('should still return results from the Dwengo API even though there are no matches in the database', async () => { + const result = await learningPathService.searchLearningPaths(TEST_DWENGO_EXCLUSIVE_LEARNING_PATH_SEARCH_QUERY, Language.Dutch); + + // Should find something... + expect(result.length).not.toBe(0); + + // But not the example learning path. + expect(result.filter((it) => it.hruid === example.learningPath.hruid && it.title === example.learningPath.title).length).toBe(0); + }); + it('should return an empty list if neither the Dwengo API nor the database contains matches', async () => { + const result = await learningPathService.searchLearningPaths(TEST_SEARCH_QUERY_EXPECTING_NO_MATCHES, Language.Dutch); + expect(result.length).toBe(0); + }); + }); +}); diff --git a/backend/tests/setup-tests.ts b/backend/tests/setup-tests.ts index 6c9d23f6..9502bcb8 100644 --- a/backend/tests/setup-tests.ts +++ b/backend/tests/setup-tests.ts @@ -1,7 +1,59 @@ -import { initORM } from '../src/orm.js'; +import { forkEntityManager, initORM } from '../src/orm.js'; import dotenv from 'dotenv'; +import { makeTestStudents } from './test_assets/users/students.testdata.js'; +import { makeTestTeachers } from './test_assets/users/teachers.testdata.js'; +import { makeTestLearningObjects } from './test_assets/content/learning-objects.testdata.js'; +import { makeTestLearningPaths } from './test_assets/content/learning-paths.testdata.js'; +import { makeTestClasses } from './test_assets/classes/classes.testdata.js'; +import { makeTestAssignemnts } from './test_assets/assignments/assignments.testdata.js'; +import { makeTestGroups } from './test_assets/assignments/groups.testdata.js'; +import { makeTestTeacherInvitations } from './test_assets/classes/teacher-invitations.testdata.js'; +import { makeTestClassJoinRequests } from './test_assets/classes/class-join-requests.testdata.js'; +import { makeTestAttachments } from './test_assets/content/attachments.testdata.js'; +import { makeTestQuestions } from './test_assets/questions/questions.testdata.js'; +import { makeTestAnswers } from './test_assets/questions/answers.testdata.js'; +import { makeTestSubmissions } from './test_assets/assignments/submission.testdata.js'; export async function setupTestApp() { dotenv.config({ path: '.env.test' }); await initORM(true); + + const em = forkEntityManager(); + + const students = makeTestStudents(em); + const teachers = makeTestTeachers(em); + const learningObjects = makeTestLearningObjects(em); + const learningPaths = makeTestLearningPaths(em); + const classes = makeTestClasses(em, students, teachers); + const assignments = makeTestAssignemnts(em, classes); + const groups = makeTestGroups(em, students, assignments); + + assignments[0].groups = groups.slice(0, 3); + assignments[1].groups = groups.slice(3, 4); + + const teacherInvitations = makeTestTeacherInvitations(em, teachers, classes); + const classJoinRequests = makeTestClassJoinRequests(em, students, classes); + const attachments = makeTestAttachments(em, learningObjects); + + learningObjects[1].attachments = attachments; + + const questions = makeTestQuestions(em, students); + const answers = makeTestAnswers(em, teachers, questions); + const submissions = makeTestSubmissions(em, students, groups); + + await em.persistAndFlush([ + ...students, + ...teachers, + ...learningObjects, + ...learningPaths, + ...classes, + ...assignments, + ...groups, + ...teacherInvitations, + ...classJoinRequests, + ...attachments, + ...questions, + ...answers, + ...submissions, + ]); } diff --git a/backend/tests/test-assets/learning-objects/create-example-learning-object-with-attachments.ts b/backend/tests/test-assets/learning-objects/create-example-learning-object-with-attachments.ts new file mode 100644 index 00000000..9bd0b4c3 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/create-example-learning-object-with-attachments.ts @@ -0,0 +1,10 @@ +import { LearningObjectExample } from './learning-object-example'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity'; + +export function createExampleLearningObjectWithAttachments(example: LearningObjectExample): LearningObject { + const learningObject = example.createLearningObject(); + for (const creationFn of Object.values(example.createAttachment)) { + learningObject.attachments.push(creationFn(learningObject)); + } + return learningObject; +} diff --git a/backend/tests/test-assets/learning-objects/dummy/dummy-learning-object-example.ts b/backend/tests/test-assets/learning-objects/dummy/dummy-learning-object-example.ts new file mode 100644 index 00000000..2f2e78ad --- /dev/null +++ b/backend/tests/test-assets/learning-objects/dummy/dummy-learning-object-example.ts @@ -0,0 +1,32 @@ +import { LearningObjectExample } from '../learning-object-example'; +import { LearningObject } from '../../../../src/entities/content/learning-object.entity'; +import { Language } from '../../../../src/entities/content/language'; +import { loadTestAsset } from '../../../test-utils/load-test-asset'; +import { DwengoContentType } from '../../../../src/services/learning-objects/processing/content-type'; +import { EnvVars, getEnvVar } from '../../../../src/util/envvars'; + +/** + * Create a dummy learning object to be used in tests where multiple learning objects are needed (for example for use + * on a path), but where the precise contents of the learning object are not important. + */ +export function dummyLearningObject(hruid: string, language: Language, title: string): LearningObjectExample { + return { + createLearningObject: () => { + const learningObject = new LearningObject(); + learningObject.hruid = getEnvVar(EnvVars.UserContentPrefix) + hruid; + learningObject.language = language; + learningObject.version = 1; + learningObject.title = title; + learningObject.description = 'Just a dummy learning object for testing purposes'; + learningObject.contentType = DwengoContentType.TEXT_PLAIN; + learningObject.content = Buffer.from('Dummy content'); + learningObject.returnValue = { + callbackUrl: `/learningObject/${hruid}/submissions`, + callbackSchema: '[]', + }; + return learningObject; + }, + createAttachment: {}, + getHTMLRendering: () => loadTestAsset('learning-objects/dummy/rendering.txt').toString(), + }; +} diff --git a/backend/tests/test-assets/learning-objects/dummy/rendering.txt b/backend/tests/test-assets/learning-objects/dummy/rendering.txt new file mode 100644 index 00000000..f3ae8006 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/dummy/rendering.txt @@ -0,0 +1 @@ +Dummy content diff --git a/backend/tests/test-assets/learning-objects/learning-object-example.d.ts b/backend/tests/test-assets/learning-objects/learning-object-example.d.ts new file mode 100644 index 00000000..1d4009f8 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/learning-object-example.d.ts @@ -0,0 +1,8 @@ +import { LearningObject } from '../../../src/entities/content/learning-object.entity'; +import { Attachment } from '../../../src/entities/content/attachment.entity'; + +type LearningObjectExample = { + createLearningObject: () => LearningObject; + createAttachment: { [key: string]: (owner: LearningObject) => Attachment }; + getHTMLRendering: () => string; +}; diff --git a/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/Knop.png b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/Knop.png new file mode 100644 index 00000000..34920e91 Binary files /dev/null and b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/Knop.png differ diff --git a/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/content.md b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/content.md new file mode 100644 index 00000000..f469e46c --- /dev/null +++ b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/content.md @@ -0,0 +1,26 @@ +# Werken met notebooks + +Het lesmateriaal van 'Python in wiskunde en STEM' wordt aangeboden in de vorm van interactieve **_notebooks_**. Notebooks zijn _digitale documenten_ die zowel uitvoerbare code bevatten als tekst, afbeeldingen, video, hyperlinks ... + +_Nieuwe begrippen_ worden aangebracht via tekstuele uitleg, video en afbeeldingen. + +Er zijn uitgewerkte _voorbeelden_ met daarnaast ook kleine en grote _opdrachten_. In deze opdrachten zal je aangereikte code kunnen uitvoeren, maar ook zelf code opstellen. + +De code die in de notebooks gebruikt wordt, is Python versie 3. We kozen voor Python omdat dit een heel toegankelijke programmeertaal is, die vaak ook intuïtief is. +Python is bovendien bezig aan een opmars en wordt gebruikt door bedrijven, zoals Google, NASA, Netflix, Uber, AstraZeneca, Barco, Instagram en YouTube. + +We kozen voor notebooks omdat daar enkele belangrijke voordelen aan verbonden zijn: leerkrachten moeten geen geavanceerde installaties doen om de notebooks te gebruiken, leerkrachten kunnen verschillende soorten van lesinhouden aanbieden via één platform, de notebooks zijn interactief, leerlingen bouwen de oplossing van een probleem stap voor stap op in de notebook waardoor dat proces zichtbaar is voor de leerkracht ([Jeroen Van der Hooft, 2023](https://libstore.ugent.be/fulltxt/RUG01/003/151/437/RUG01-003151437_2023_0001_AC.pdf)). + +--- + +Klik je op onderstaande knop 'Open notebooks', dan word je doorgestuurd naar een andere website waar jouw persoonlijke notebooks ingeladen worden. (Dit kan even duren.) + +Links op het scherm vind je er twee bestanden met extensie _.ipynb_. +Dit zijn de twee notebooks waarin je resp. een overzicht krijgt van de opbouw en mogelijkheden en hoe je er mee aan de slag kan. Dubbelklik op de bestandsnaam om een notebook te openen. + +Je ziet er ook een map _images_ met de afbeeldingen die in de notebooks getoond worden. + +In deze eerste twee notebooks leer je hoe de notebooks zijn opgevat en hoe je ermee aan de slag kan. +Na het doorlopen van beide notebooks heb je een goed idee van hoe onze Python notebooks zijn opgevat. + +[![](Knop.png 'Knop')](https://kiks.ilabt.imec.be/hub/tmplogin?id=0101 'Notebooks Werking') diff --git a/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/dwengo.png b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/dwengo.png new file mode 100644 index 00000000..b03fcd08 Binary files /dev/null and b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/dwengo.png differ diff --git a/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.ts b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.ts new file mode 100644 index 00000000..600a4305 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.ts @@ -0,0 +1,72 @@ +import { LearningObjectExample } from '../learning-object-example'; +import { Language } from '../../../../src/entities/content/language'; +import { DwengoContentType } from '../../../../src/services/learning-objects/processing/content-type'; +import { loadTestAsset } from '../../../test-utils/load-test-asset'; +import { EducationalGoal, LearningObject, ReturnValue } from '../../../../src/entities/content/learning-object.entity'; +import { Attachment } from '../../../../src/entities/content/attachment.entity'; +import { EnvVars, getEnvVar } from '../../../../src/util/envvars'; + +const ASSETS_PREFIX = 'learning-objects/pn-werkingnotebooks/'; + +const example: LearningObjectExample = { + createLearningObject: () => { + const learningObject = new LearningObject(); + learningObject.hruid = `${getEnvVar(EnvVars.UserContentPrefix)}pn_werkingnotebooks`; + learningObject.version = 3; + learningObject.language = Language.Dutch; + learningObject.title = 'Werken met notebooks'; + learningObject.description = 'Leren werken met notebooks'; + learningObject.keywords = ['Python', 'KIKS', 'Wiskunde', 'STEM', 'AI']; + + const educationalGoal1 = new EducationalGoal(); + educationalGoal1.source = 'Source'; + educationalGoal1.id = 'id'; + + const educationalGoal2 = new EducationalGoal(); + educationalGoal2.source = 'Source2'; + educationalGoal2.id = 'id2'; + + learningObject.educationalGoals = [educationalGoal1, educationalGoal2]; + learningObject.admins = []; + learningObject.contentType = DwengoContentType.TEXT_MARKDOWN; + learningObject.teacherExclusive = false; + learningObject.skosConcepts = [ + 'http://ilearn.ilabt.imec.be/vocab/curr1/s-vaktaal', + 'http://ilearn.ilabt.imec.be/vocab/curr1/s-digitale-media-en-toepassingen', + 'http://ilearn.ilabt.imec.be/vocab/curr1/s-computers-en-systemen', + ]; + learningObject.copyright = 'dwengo'; + learningObject.license = 'dwengo'; + learningObject.estimatedTime = 10; + + const returnValue = new ReturnValue(); + returnValue.callbackUrl = 'callback_url_example'; + returnValue.callbackSchema = '{"att": "test", "att2": "test2"}'; + + learningObject.returnValue = returnValue; + learningObject.available = true; + learningObject.content = loadTestAsset(`${ASSETS_PREFIX}/content.md`); + + return learningObject; + }, + createAttachment: { + dwengoLogo: (learningObject) => { + const att = new Attachment(); + att.learningObject = learningObject; + att.name = 'dwengo.png'; + att.mimeType = 'image/png'; + att.content = loadTestAsset(`${ASSETS_PREFIX}/dwengo.png`); + return att; + }, + knop: (learningObject) => { + const att = new Attachment(); + att.learningObject = learningObject; + att.name = 'Knop.png'; + att.mimeType = 'image/png'; + att.content = loadTestAsset(`${ASSETS_PREFIX}/Knop.png`); + return att; + }, + }, + getHTMLRendering: () => loadTestAsset(`${ASSETS_PREFIX}/rendering.txt`).toString(), +}; +export default example; diff --git a/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/rendering.txt b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/rendering.txt new file mode 100644 index 00000000..af596243 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/rendering.txt @@ -0,0 +1,20 @@ +

+ + + + Werken met notebooks +

+

Het lesmateriaal van 'Python in wiskunde en STEM' wordt aangeboden in de vorm van interactieve notebooks. Notebooks zijn digitale documenten die zowel uitvoerbare code bevatten als tekst, afbeeldingen, video, hyperlinks ...

+

Nieuwe begrippen worden aangebracht via tekstuele uitleg, video en afbeeldingen.

+

Er zijn uitgewerkte voorbeelden met daarnaast ook kleine en grote opdrachten. In deze opdrachten zal je aangereikte code kunnen uitvoeren, maar ook zelf code opstellen.

+

De code die in de notebooks gebruikt wordt, is Python versie 3. We kozen voor Python omdat dit een heel toegankelijke programmeertaal is, die vaak ook intuC/tief is. +Python is bovendien bezig aan een opmars en wordt gebruikt door bedrijven, zoals Google, NASA, Netflix, Uber, AstraZeneca, Barco, Instagram en YouTube.

+

We kozen voor notebooks omdat daar enkele belangrijke voordelen aan verbonden zijn: leerkrachten moeten geen geavanceerde installaties doen om de notebooks te gebruiken, leerkrachten kunnen verschillende soorten van lesinhouden aanbieden via C)C)n platform, de notebooks zijn interactief, leerlingen bouwen de oplossing van een probleem stap voor stap op in de notebook waardoor dat proces zichtbaar is voor de leerkracht (Jeroen Van der Hooft, 2023).

+
+

Klik je op onderstaande knop 'Open notebooks', dan word je doorgestuurd naar een andere website waar jouw persoonlijke notebooks ingeladen worden. (Dit kan even duren.)

+

Links op het scherm vind je er twee bestanden met extensie .ipynb. +Dit zijn de twee notebooks waarin je resp. een overzicht krijgt van de opbouw en mogelijkheden en hoe je er mee aan de slag kan. Dubbelklik op de bestandsnaam om een notebook te openen.

+

Je ziet er ook een map images met de afbeeldingen die in de notebooks getoond worden.

+

In deze eerste twee notebooks leer je hoe de notebooks zijn opgevat en hoe je ermee aan de slag kan. +Na het doorlopen van beide notebooks heb je een goed idee van hoe onze Python notebooks zijn opgevat.

+

diff --git a/backend/tests/test-assets/learning-objects/test-essay/content.txt b/backend/tests/test-assets/learning-objects/test-essay/content.txt new file mode 100644 index 00000000..dd6b5c77 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/test-essay/content.txt @@ -0,0 +1,2 @@ +::MC basic:: +How are you? {} diff --git a/backend/tests/test-assets/learning-objects/test-essay/rendering.txt b/backend/tests/test-assets/learning-objects/test-essay/rendering.txt new file mode 100644 index 00000000..adb072a0 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/test-essay/rendering.txt @@ -0,0 +1,7 @@ +
+
+

MC basic

+

How are you?

+ +
+
diff --git a/backend/tests/test-assets/learning-objects/test-essay/test-essay-example.ts b/backend/tests/test-assets/learning-objects/test-essay/test-essay-example.ts new file mode 100644 index 00000000..d57c7a33 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/test-essay/test-essay-example.ts @@ -0,0 +1,28 @@ +import { LearningObjectExample } from '../learning-object-example'; +import { LearningObject } from '../../../../src/entities/content/learning-object.entity'; +import { loadTestAsset } from '../../../test-utils/load-test-asset'; +import { EnvVars, getEnvVar } from '../../../../src/util/envvars'; +import { Language } from '../../../../src/entities/content/language'; +import { DwengoContentType } from '../../../../src/services/learning-objects/processing/content-type'; + +const example: LearningObjectExample = { + createLearningObject: () => { + const learningObject = new LearningObject(); + learningObject.hruid = `${getEnvVar(EnvVars.UserContentPrefix)}test_essay`; + learningObject.language = Language.English; + learningObject.version = 1; + learningObject.title = 'Essay question for testing'; + learningObject.description = 'This essay question was only created for testing purposes.'; + learningObject.contentType = DwengoContentType.GIFT; + learningObject.returnValue = { + callbackUrl: `/learningObject/${learningObject.hruid}/submissions`, + callbackSchema: '["antwoord vraag 1"]', + }; + learningObject.content = loadTestAsset('learning-objects/test-essay/content.txt'); + return learningObject; + }, + createAttachment: {}, + getHTMLRendering: () => loadTestAsset('learning-objects/test-essay/rendering.txt').toString(), +}; + +export default example; diff --git a/backend/tests/test-assets/learning-objects/test-multiple-choice/content.txt b/backend/tests/test-assets/learning-objects/test-multiple-choice/content.txt new file mode 100644 index 00000000..7dd5527d --- /dev/null +++ b/backend/tests/test-assets/learning-objects/test-multiple-choice/content.txt @@ -0,0 +1,5 @@ +::MC basic:: +Are you following along well with the class? { + ~No, it's very difficult to follow along. + =Yes, no problem! +} diff --git a/backend/tests/test-assets/learning-objects/test-multiple-choice/rendering.txt b/backend/tests/test-assets/learning-objects/test-multiple-choice/rendering.txt new file mode 100644 index 00000000..c1829f24 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/test-multiple-choice/rendering.txt @@ -0,0 +1,14 @@ +
+
+

MC basic

+

Are you following along well with the class?

+
+ + +
+
+ + +
+
+
diff --git a/backend/tests/test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example.ts b/backend/tests/test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example.ts new file mode 100644 index 00000000..a634878a --- /dev/null +++ b/backend/tests/test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example.ts @@ -0,0 +1,28 @@ +import { LearningObjectExample } from '../learning-object-example'; +import { LearningObject } from '../../../../src/entities/content/learning-object.entity'; +import { loadTestAsset } from '../../../test-utils/load-test-asset'; +import { EnvVars, getEnvVar } from '../../../../src/util/envvars'; +import { Language } from '../../../../src/entities/content/language'; +import { DwengoContentType } from '../../../../src/services/learning-objects/processing/content-type'; + +const example: LearningObjectExample = { + createLearningObject: () => { + const learningObject = new LearningObject(); + learningObject.hruid = `${getEnvVar(EnvVars.UserContentPrefix)}test_multiple_choice`; + learningObject.language = Language.English; + learningObject.version = 1; + learningObject.title = 'Multiple choice question for testing'; + learningObject.description = 'This multiple choice question was only created for testing purposes.'; + learningObject.contentType = DwengoContentType.GIFT; + learningObject.returnValue = { + callbackUrl: `/learningObject/${learningObject.hruid}/submissions`, + callbackSchema: '["antwoord vraag 1"]', + }; + learningObject.content = loadTestAsset('learning-objects/test-multiple-choice/content.txt'); + return learningObject; + }, + createAttachment: {}, + getHTMLRendering: () => loadTestAsset('learning-objects/test-multiple-choice/rendering.txt').toString(), +}; + +export default example; diff --git a/backend/tests/test-assets/learning-paths/learning-path-example.d.ts b/backend/tests/test-assets/learning-paths/learning-path-example.d.ts new file mode 100644 index 00000000..9df3ba48 --- /dev/null +++ b/backend/tests/test-assets/learning-paths/learning-path-example.d.ts @@ -0,0 +1,3 @@ +type LearningPathExample = { + createLearningPath: () => LearningPath; +}; diff --git a/backend/tests/test-assets/learning-paths/learning-path-utils.ts b/backend/tests/test-assets/learning-paths/learning-path-utils.ts new file mode 100644 index 00000000..c567de66 --- /dev/null +++ b/backend/tests/test-assets/learning-paths/learning-path-utils.ts @@ -0,0 +1,31 @@ +import { Language } from '../../../src/entities/content/language'; +import { LearningPathTransition } from '../../../src/entities/content/learning-path-transition.entity'; +import { LearningPathNode } from '../../../src/entities/content/learning-path-node.entity'; +import { LearningPath } from '../../../src/entities/content/learning-path.entity'; + +export function createLearningPathTransition(node: LearningPathNode, transitionNumber: number, condition: string | null, to: LearningPathNode) { + const trans = new LearningPathTransition(); + trans.node = node; + trans.transitionNumber = transitionNumber; + trans.condition = condition || 'true'; + trans.next = to; + return trans; +} + +export function createLearningPathNode( + learningPath: LearningPath, + nodeNumber: number, + learningObjectHruid: string, + version: number, + language: Language, + startNode: boolean +) { + const node = new LearningPathNode(); + node.learningPath = learningPath; + node.nodeNumber = nodeNumber; + node.learningObjectHruid = learningObjectHruid; + node.version = version; + node.language = language; + node.startNode = startNode; + return node; +} diff --git a/backend/tests/test-assets/learning-paths/pn-werking-example.ts b/backend/tests/test-assets/learning-paths/pn-werking-example.ts new file mode 100644 index 00000000..810b4da5 --- /dev/null +++ b/backend/tests/test-assets/learning-paths/pn-werking-example.ts @@ -0,0 +1,30 @@ +import { LearningPath } from '../../../src/entities/content/learning-path.entity'; +import { Language } from '../../../src/entities/content/language'; +import { EnvVars, getEnvVar } from '../../../src/util/envvars'; +import { createLearningPathNode, createLearningPathTransition } from './learning-path-utils'; +import { LearningPathNode } from '../../../src/entities/content/learning-path-node.entity'; + +function createNodes(learningPath: LearningPath): LearningPathNode[] { + const nodes = [ + createLearningPathNode(learningPath, 0, 'u_pn_werkingnotebooks', 3, Language.Dutch, true), + createLearningPathNode(learningPath, 1, 'pn_werkingnotebooks2', 3, Language.Dutch, false), + createLearningPathNode(learningPath, 2, 'pn_werkingnotebooks3', 3, Language.Dutch, false), + ]; + nodes[0].transitions.push(createLearningPathTransition(nodes[0], 0, 'true', nodes[1])); + nodes[1].transitions.push(createLearningPathTransition(nodes[1], 0, 'true', nodes[2])); + return nodes; +} + +const example: LearningPathExample = { + createLearningPath: () => { + const path = new LearningPath(); + path.language = Language.Dutch; + path.hruid = `${getEnvVar(EnvVars.UserContentPrefix)}pn_werking`; + path.title = 'Werken met notebooks'; + path.description = 'Een korte inleiding tot Python notebooks. Hoe ga je gemakkelijk en efficiënt met de notebooks aan de slag?'; + path.nodes = createNodes(path); + return path; + }, +}; + +export default example; diff --git a/backend/tests/test-assets/learning-paths/test-conditions-example.ts b/backend/tests/test-assets/learning-paths/test-conditions-example.ts new file mode 100644 index 00000000..07857235 --- /dev/null +++ b/backend/tests/test-assets/learning-paths/test-conditions-example.ts @@ -0,0 +1,84 @@ +import { LearningPath } from '../../../src/entities/content/learning-path.entity'; +import { Language } from '../../../src/entities/content/language'; +import testMultipleChoiceExample from '../learning-objects/test-multiple-choice/test-multiple-choice-example'; +import { dummyLearningObject } from '../learning-objects/dummy/dummy-learning-object-example'; +import { createLearningPathNode, createLearningPathTransition } from './learning-path-utils'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity'; +import { EnvVars, getEnvVar } from '../../../src/util/envvars'; + +export type ConditionTestLearningPathAndLearningObjects = { + branchingObject: LearningObject; + extraExerciseObject: LearningObject; + finalObject: LearningObject; + learningPath: LearningPath; +}; + +export function createConditionTestLearningPathAndLearningObjects() { + const learningPath = new LearningPath(); + learningPath.hruid = `${getEnvVar(EnvVars.UserContentPrefix)}test_conditions`; + learningPath.language = Language.English; + learningPath.title = 'Example learning path with conditional transitions'; + learningPath.description = 'This learning path was made for the purpose of testing conditional transitions'; + + const branchingLearningObject = testMultipleChoiceExample.createLearningObject(); + const extraExerciseLearningObject = dummyLearningObject( + 'test_extra_exercise', + Language.English, + 'Extra exercise (for students with difficulties)' + ).createLearningObject(); + const finalLearningObject = dummyLearningObject( + 'test_final_learning_object', + Language.English, + 'Final exercise (for everyone)' + ).createLearningObject(); + + const branchingNode = createLearningPathNode( + learningPath, + 0, + branchingLearningObject.hruid, + branchingLearningObject.version, + branchingLearningObject.language, + true + ); + const extraExerciseNode = createLearningPathNode( + learningPath, + 1, + extraExerciseLearningObject.hruid, + extraExerciseLearningObject.version, + extraExerciseLearningObject.language, + false + ); + const finalNode = createLearningPathNode( + learningPath, + 2, + finalLearningObject.hruid, + finalLearningObject.version, + finalLearningObject.language, + false + ); + + const transitionToExtraExercise = createLearningPathTransition( + branchingNode, + 0, + '$[?(@[0] == 0)]', // The answer to the first question was the first one, which says that it is difficult for the student to follow along. + extraExerciseNode + ); + const directTransitionToFinal = createLearningPathTransition(branchingNode, 1, '$[?(@[0] == 1)]', finalNode); + const transitionExtraExerciseToFinal = createLearningPathTransition(extraExerciseNode, 0, 'true', finalNode); + + branchingNode.transitions = [transitionToExtraExercise, directTransitionToFinal]; + extraExerciseNode.transitions = [transitionExtraExerciseToFinal]; + + learningPath.nodes = [branchingNode, extraExerciseNode, finalNode]; + + return { + branchingObject: branchingLearningObject, + finalObject: finalLearningObject, + extraExerciseObject: extraExerciseLearningObject, + learningPath: learningPath, + }; +} + +const example: LearningPathExample = { + createLearningPath: () => createConditionTestLearningPathAndLearningObjects().learningPath, +}; diff --git a/backend/tests/test-utils/expectations.ts b/backend/tests/test-utils/expectations.ts new file mode 100644 index 00000000..0fe63811 --- /dev/null +++ b/backend/tests/test-utils/expectations.ts @@ -0,0 +1,150 @@ +import { AssertionError } from 'node:assert'; +import { LearningObject } from '../../src/entities/content/learning-object.entity'; +import { FilteredLearningObject, LearningPath } from '../../src/interfaces/learning-content'; +import { LearningPath as LearningPathEntity } from '../../src/entities/content/learning-path.entity'; +import { expect } from 'vitest'; + +// Ignored properties because they belang for example to the class, not to the entity itself. +const IGNORE_PROPERTIES = ['parent']; + +/** + * Checks if the actual entity from the database conforms to the entity that was added previously. + * @param actual The actual entity retrieved from the database + * @param expected The (previously added) entity we would expect to retrieve + */ +export function expectToBeCorrectEntity(actual: { entity: T; name?: string }, expected: { entity: T; name?: string }): void { + if (!actual.name) { + actual.name = 'actual'; + } + if (!expected.name) { + expected.name = 'expected'; + } + for (const property in expected.entity) { + if ( + property! in IGNORE_PROPERTIES && + expected.entity[property] !== undefined && // If we don't expect a certain value for a property, we assume it can be filled in by the database however it wants. + typeof expected.entity[property] !== 'function' // Functions obviously are not persisted via the database + ) { + if (!actual.entity.hasOwnProperty(property)) { + throw new AssertionError({ + message: `${expected.name} has defined property ${property}, but ${actual.name} is missing it.`, + }); + } + if (typeof expected.entity[property] === 'boolean') { + // Sometimes, booleans get represented by numbers 0 and 1 in the objects actual from the database. + if (Boolean(expected.entity[property]) !== Boolean(actual.entity[property])) { + throw new AssertionError({ + message: `${property} was ${expected.entity[property]} in ${expected.name}, + but ${actual.entity[property]} (${Boolean(expected.entity[property])}) in ${actual.name}`, + }); + } + } else if (typeof expected.entity[property] !== typeof actual.entity[property]) { + throw new AssertionError({ + message: `${property} has type ${typeof expected.entity[property]} in ${expected.name}, but type ${typeof actual.entity[property]} in ${actual.name}.`, + }); + } else if (typeof expected.entity[property] === 'object') { + expectToBeCorrectEntity( + { + name: actual.name + '.' + property, + entity: actual.entity[property] as object, + }, + { + name: expected.name + '.' + property, + entity: expected.entity[property] as object, + } + ); + } else { + if (expected.entity[property] !== actual.entity[property]) { + throw new AssertionError({ + message: `${property} was ${expected.entity[property]} in ${expected.name}, but ${actual.entity[property]} in ${actual.name}`, + }); + } + } + } + } +} + +/** + * Checks that filtered is the correct representation of original as FilteredLearningObject. + * @param filtered the representation as FilteredLearningObject + * @param original the original entity added to the database + */ +export function expectToBeCorrectFilteredLearningObject(filtered: FilteredLearningObject, original: LearningObject) { + expect(filtered.uuid).toEqual(original.uuid); + expect(filtered.version).toEqual(original.version); + expect(filtered.language).toEqual(original.language); + expect(filtered.keywords).toEqual(original.keywords); + expect(filtered.key).toEqual(original.hruid); + expect(filtered.targetAges).toEqual(original.targetAges); + expect(filtered.title).toEqual(original.title); + expect(Boolean(filtered.teacherExclusive)).toEqual(Boolean(original.teacherExclusive)); + expect(filtered.skosConcepts).toEqual(original.skosConcepts); + expect(filtered.estimatedTime).toEqual(original.estimatedTime); + expect(filtered.educationalGoals).toEqual(original.educationalGoals); + expect(filtered.difficulty).toEqual(original.difficulty || 1); + expect(filtered.description).toEqual(original.description); + expect(filtered.returnValue?.callback_url).toEqual(original.returnValue.callbackUrl); + expect(filtered.returnValue?.callback_schema).toEqual(JSON.parse(original.returnValue.callbackSchema)); + expect(filtered.contentType).toEqual(original.contentType); + expect(filtered.contentLocation || null).toEqual(original.contentLocation || null); + expect(filtered.htmlUrl).toContain(`/${original.hruid}/html`); + expect(filtered.htmlUrl).toContain(`language=${original.language}`); + expect(filtered.htmlUrl).toContain(`version=${original.version}`); +} + +/** + * Check that a learning path returned by a LearningPathRetriever, the LearningPathService or an API endpoint + * is a correct representation of the given learning path entity. + * + * @param learningPath The learning path returned by the retriever, service or endpoint + * @param expectedEntity The expected entity + * @param learningObjectsOnPath The learning objects on LearningPath. Necessary since some information in + * the learning path returned from the API endpoint + */ +export function expectToBeCorrectLearningPath( + learningPath: LearningPath, + expectedEntity: LearningPathEntity, + learningObjectsOnPath: FilteredLearningObject[] +) { + expect(learningPath.hruid).toEqual(expectedEntity.hruid); + expect(learningPath.language).toEqual(expectedEntity.language); + expect(learningPath.description).toEqual(expectedEntity.description); + expect(learningPath.title).toEqual(expectedEntity.title); + + const keywords = new Set(learningObjectsOnPath.flatMap((it) => it.keywords || [])); + expect(new Set(learningPath.keywords.split(' '))).toEqual(keywords); + + const targetAges = new Set(learningObjectsOnPath.flatMap((it) => it.targetAges || [])); + expect(new Set(learningPath.target_ages)).toEqual(targetAges); + expect(learningPath.min_age).toEqual(Math.min(...targetAges)); + expect(learningPath.max_age).toEqual(Math.max(...targetAges)); + + expect(learningPath.num_nodes).toEqual(expectedEntity.nodes.length); + expect(learningPath.image || null).toEqual(expectedEntity.image); + + const expectedLearningPathNodes = new Map( + expectedEntity.nodes.map((node) => [ + { learningObjectHruid: node.learningObjectHruid, language: node.language, version: node.version }, + { startNode: node.startNode, transitions: node.transitions }, + ]) + ); + + for (const node of learningPath.nodes) { + const nodeKey = { + learningObjectHruid: node.learningobject_hruid, + language: node.language, + version: node.version, + }; + expect(expectedLearningPathNodes.keys()).toContainEqual(nodeKey); + const expectedNode = [...expectedLearningPathNodes.entries()].filter( + ([key, _]) => key.learningObjectHruid === nodeKey.learningObjectHruid && key.language === node.language && key.version === node.version + )[0][1]; + expect(node.start_node).toEqual(expectedNode?.startNode); + + expect(new Set(node.transitions.map((it) => it.next.hruid))).toEqual( + new Set(expectedNode.transitions.map((it) => it.next.learningObjectHruid)) + ); + expect(new Set(node.transitions.map((it) => it.next.language))).toEqual(new Set(expectedNode.transitions.map((it) => it.next.language))); + expect(new Set(node.transitions.map((it) => it.next.version))).toEqual(new Set(expectedNode.transitions.map((it) => it.next.version))); + } +} diff --git a/backend/tests/test-utils/load-test-asset.ts b/backend/tests/test-utils/load-test-asset.ts new file mode 100644 index 00000000..35f6cdbf --- /dev/null +++ b/backend/tests/test-utils/load-test-asset.ts @@ -0,0 +1,10 @@ +import fs from 'fs'; +import path from 'node:path'; + +/** + * Load the asset at the given path. + * @param relPath Path of the asset relative to the test-assets folder. + */ +export function loadTestAsset(relPath: string): Buffer { + return fs.readFileSync(path.resolve(__dirname, `../test-assets/${relPath}`)); +} diff --git a/backend/tests/test_assets/assignments/assignments.testdata.ts b/backend/tests/test_assets/assignments/assignments.testdata.ts new file mode 100644 index 00000000..7f909de4 --- /dev/null +++ b/backend/tests/test_assets/assignments/assignments.testdata.ts @@ -0,0 +1,38 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Assignment } from '../../../src/entities/assignments/assignment.entity'; +import { Class } from '../../../src/entities/classes/class.entity'; +import { Language } from '../../../src/entities/content/language'; + +export function makeTestAssignemnts(em: EntityManager>, classes: Array): Array { + const assignment01 = em.create(Assignment, { + within: classes[0], + id: 1, + title: 'dire straits', + description: 'reading', + learningPathHruid: 'id02', + learningPathLanguage: Language.English, + groups: [], + }); + + const assignment02 = em.create(Assignment, { + within: classes[1], + id: 2, + title: 'tool', + description: 'reading', + learningPathHruid: 'id01', + learningPathLanguage: Language.English, + groups: [], + }); + + const assignment03 = em.create(Assignment, { + within: classes[0], + id: 3, + title: 'delete', + description: 'will be deleted', + learningPathHruid: 'id02', + learningPathLanguage: Language.English, + groups: [], + }); + + return [assignment01, assignment02, assignment03]; +} diff --git a/backend/tests/test_assets/assignments/groups.testdata.ts b/backend/tests/test_assets/assignments/groups.testdata.ts new file mode 100644 index 00000000..0e9ef201 --- /dev/null +++ b/backend/tests/test_assets/assignments/groups.testdata.ts @@ -0,0 +1,36 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Group } from '../../../src/entities/assignments/group.entity'; +import { Assignment } from '../../../src/entities/assignments/assignment.entity'; +import { Student } from '../../../src/entities/users/student.entity'; + +export function makeTestGroups( + em: EntityManager>, + students: Array, + assignments: Array +): Array { + const group01 = em.create(Group, { + assignment: assignments[0], + groupNumber: 1, + members: students.slice(0, 2), + }); + + const group02 = em.create(Group, { + assignment: assignments[0], + groupNumber: 2, + members: students.slice(2, 4), + }); + + const group03 = em.create(Group, { + assignment: assignments[0], + groupNumber: 3, + members: students.slice(4, 6), + }); + + const group04 = em.create(Group, { + assignment: assignments[1], + groupNumber: 4, + members: students.slice(3, 4), + }); + + return [group01, group02, group03, group04]; +} diff --git a/backend/tests/test_assets/assignments/submission.testdata.ts b/backend/tests/test_assets/assignments/submission.testdata.ts new file mode 100644 index 00000000..058af70f --- /dev/null +++ b/backend/tests/test_assets/assignments/submission.testdata.ts @@ -0,0 +1,65 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Submission } from '../../../src/entities/assignments/submission.entity'; +import { Language } from '../../../src/entities/content/language'; +import { Student } from '../../../src/entities/users/student.entity'; +import { Group } from '../../../src/entities/assignments/group.entity'; + +export function makeTestSubmissions( + em: EntityManager>, + students: Array, + groups: Array +): Array { + const submission01 = em.create(Submission, { + learningObjectHruid: 'id03', + learningObjectLanguage: Language.English, + learningObjectVersion: '1', + submissionNumber: 1, + submitter: students[0], + submissionTime: new Date(2025, 2, 20), + onBehalfOf: groups[0], + content: 'sub1', + }); + + const submission02 = em.create(Submission, { + learningObjectHruid: 'id03', + learningObjectLanguage: Language.English, + learningObjectVersion: '1', + submissionNumber: 2, + submitter: students[0], + submissionTime: new Date(2025, 2, 25), + onBehalfOf: groups[0], + content: '', + }); + + const submission03 = em.create(Submission, { + learningObjectHruid: 'id02', + learningObjectLanguage: Language.English, + learningObjectVersion: '1', + submissionNumber: 1, + submitter: students[0], + submissionTime: new Date(2025, 2, 20), + content: '', + }); + + const submission04 = em.create(Submission, { + learningObjectHruid: 'id02', + learningObjectLanguage: Language.English, + learningObjectVersion: '1', + submissionNumber: 2, + submitter: students[0], + submissionTime: new Date(2025, 2, 25), + content: '', + }); + + const submission05 = em.create(Submission, { + learningObjectHruid: 'id01', + learningObjectLanguage: Language.English, + learningObjectVersion: '1', + submissionNumber: 1, + submitter: students[1], + submissionTime: new Date(2025, 2, 20), + content: '', + }); + + return [submission01, submission02, submission03, submission04, submission05]; +} diff --git a/backend/tests/test_assets/classes/class-join-requests.testdata.ts b/backend/tests/test_assets/classes/class-join-requests.testdata.ts new file mode 100644 index 00000000..8d9e328f --- /dev/null +++ b/backend/tests/test_assets/classes/class-join-requests.testdata.ts @@ -0,0 +1,36 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { ClassJoinRequest, ClassJoinRequestStatus } from '../../../src/entities/classes/class-join-request.entity'; +import { Student } from '../../../src/entities/users/student.entity'; +import { Class } from '../../../src/entities/classes/class.entity'; + +export function makeTestClassJoinRequests( + em: EntityManager>, + students: Array, + classes: Array +): Array { + const classJoinRequest01 = em.create(ClassJoinRequest, { + requester: students[4], + class: classes[1], + status: ClassJoinRequestStatus.Open, + }); + + const classJoinRequest02 = em.create(ClassJoinRequest, { + requester: students[2], + class: classes[1], + status: ClassJoinRequestStatus.Open, + }); + + const classJoinRequest03 = em.create(ClassJoinRequest, { + requester: students[4], + class: classes[2], + status: ClassJoinRequestStatus.Open, + }); + + const classJoinRequest04 = em.create(ClassJoinRequest, { + requester: students[3], + class: classes[2], + status: ClassJoinRequestStatus.Open, + }); + + return [classJoinRequest01, classJoinRequest02, classJoinRequest03, classJoinRequest04]; +} diff --git a/backend/tests/test_assets/classes/classes.testdata.ts b/backend/tests/test_assets/classes/classes.testdata.ts new file mode 100644 index 00000000..b3e98bc8 --- /dev/null +++ b/backend/tests/test_assets/classes/classes.testdata.ts @@ -0,0 +1,48 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Class } from '../../../src/entities/classes/class.entity'; +import { Student } from '../../../src/entities/users/student.entity'; +import { Teacher } from '../../../src/entities/users/teacher.entity'; + +export function makeTestClasses(em: EntityManager>, students: Array, teachers: Array): Array { + const studentsClass01 = students.slice(0, 7); + const teacherClass01: Array = teachers.slice(0, 1); + + const class01 = em.create(Class, { + classId: 'id01', + displayName: 'class01', + teachers: teacherClass01, + students: studentsClass01, + }); + + const studentsClass02: Array = students.slice(0, 2).concat(students.slice(3, 4)); + const teacherClass02: Array = teachers.slice(1, 2); + + const class02 = em.create(Class, { + classId: 'id02', + displayName: 'class02', + teachers: teacherClass02, + students: studentsClass02, + }); + + const studentsClass03: Array = students.slice(1, 4); + const teacherClass03: Array = teachers.slice(2, 3); + + const class03 = em.create(Class, { + classId: 'id03', + displayName: 'class03', + teachers: teacherClass03, + students: studentsClass03, + }); + + const studentsClass04: Array = students.slice(0, 2); + const teacherClass04: Array = teachers.slice(2, 3); + + const class04 = em.create(Class, { + classId: 'id04', + displayName: 'class04', + teachers: teacherClass04, + students: studentsClass04, + }); + + return [class01, class02, class03, class04]; +} diff --git a/backend/tests/test_assets/classes/teacher-invitations.testdata.ts b/backend/tests/test_assets/classes/teacher-invitations.testdata.ts new file mode 100644 index 00000000..84eeab01 --- /dev/null +++ b/backend/tests/test_assets/classes/teacher-invitations.testdata.ts @@ -0,0 +1,36 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { TeacherInvitation } from '../../../src/entities/classes/teacher-invitation.entity'; +import { Teacher } from '../../../src/entities/users/teacher.entity'; +import { Class } from '../../../src/entities/classes/class.entity'; + +export function makeTestTeacherInvitations( + em: EntityManager>, + teachers: Array, + classes: Array +): Array { + const teacherInvitation01 = em.create(TeacherInvitation, { + sender: teachers[1], + receiver: teachers[0], + class: classes[1], + }); + + const teacherInvitation02 = em.create(TeacherInvitation, { + sender: teachers[1], + receiver: teachers[2], + class: classes[1], + }); + + const teacherInvitation03 = em.create(TeacherInvitation, { + sender: teachers[2], + receiver: teachers[0], + class: classes[2], + }); + + const teacherInvitation04 = em.create(TeacherInvitation, { + sender: teachers[0], + receiver: teachers[1], + class: classes[0], + }); + + return [teacherInvitation01, teacherInvitation02, teacherInvitation03, teacherInvitation04]; +} diff --git a/backend/tests/test_assets/content/attachments.testdata.ts b/backend/tests/test_assets/content/attachments.testdata.ts new file mode 100644 index 00000000..9f690d9c --- /dev/null +++ b/backend/tests/test_assets/content/attachments.testdata.ts @@ -0,0 +1,14 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Attachment } from '../../../src/entities/content/attachment.entity'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity'; + +export function makeTestAttachments(em: EntityManager>, learningObjects: Array): Array { + const attachment01 = em.create(Attachment, { + learningObject: learningObjects[1], + name: 'attachment01', + mimeType: '', + content: Buffer.from(''), + }); + + return [attachment01]; +} diff --git a/backend/tests/test_assets/content/learning-objects.testdata.ts b/backend/tests/test_assets/content/learning-objects.testdata.ts new file mode 100644 index 00000000..17ed4f01 --- /dev/null +++ b/backend/tests/test_assets/content/learning-objects.testdata.ts @@ -0,0 +1,134 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { LearningObject, ReturnValue } from '../../../src/entities/content/learning-object.entity'; +import { Language } from '../../../src/entities/content/language'; +import { DwengoContentType } from '../../../src/services/learning-objects/processing/content-type'; + +export function makeTestLearningObjects(em: EntityManager>): Array { + const returnValue: ReturnValue = new ReturnValue(); + returnValue.callbackSchema = ''; + returnValue.callbackUrl = ''; + + const learningObject01 = em.create(LearningObject, { + hruid: 'id01', + language: Language.English, + version: 1, + admins: [], + title: 'Undertow', + description: 'debute', + contentType: DwengoContentType.TEXT_MARKDOWN, + keywords: [], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: '', + license: '', + estimatedTime: 45, + returnValue: returnValue, + available: true, + contentLocation: '', + attachments: [], + content: Buffer.from("there's a shadow just behind me, shrouding every step i take, making every promise empty pointing every finger at me"), + }); + + const learningObject02 = em.create(LearningObject, { + hruid: 'id02', + language: Language.English, + version: 1, + admins: [], + title: 'Aenema', + description: 'second album', + contentType: DwengoContentType.TEXT_MARKDOWN, + keywords: [], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: '', + license: '', + estimatedTime: 80, + returnValue: returnValue, + available: true, + contentLocation: '', + attachments: [], + content: Buffer.from( + "I've been crawling on my belly clearing out what could've been I've been wallowing in my own confused and insecure delusions" + ), + }); + + const learningObject03 = em.create(LearningObject, { + hruid: 'id03', + language: Language.English, + version: 1, + admins: [], + title: 'love over gold', + description: 'third album', + contentType: DwengoContentType.TEXT_MARKDOWN, + keywords: [], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: '', + license: '', + estimatedTime: 55, + returnValue: returnValue, + available: true, + contentLocation: '', + attachments: [], + content: Buffer.from( + 'he wrote me a prescription, he said you are depressed, \ + but I am glad you came to see me to get this off your chest, \ + come back and see me later next patient please \ + send in another victim of industrial disease' + ), + }); + + const learningObject04 = em.create(LearningObject, { + hruid: 'id04', + language: Language.English, + version: 1, + admins: [], + title: 'making movies', + description: 'fifth album', + contentType: DwengoContentType.TEXT_MARKDOWN, + keywords: [], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: '', + license: '', + estimatedTime: 55, + returnValue: returnValue, + available: true, + contentLocation: '', + attachments: [], + content: Buffer.from( + 'I put my hand upon the lever \ + Said let it rock and let it roll \ + I had the one-arm bandit fever \ + There was an arrow through my heart and my soul' + ), + }); + + const learningObject05 = em.create(LearningObject, { + hruid: 'id05', + language: Language.English, + version: 1, + admins: [], + title: 'on every street', + description: 'sixth album', + contentType: DwengoContentType.TEXT_MARKDOWN, + keywords: [], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: '', + license: '', + estimatedTime: 55, + returnValue: returnValue, + available: true, + contentLocation: '', + attachments: [], + content: Buffer.from('calling Elvis, is anybody home, calling elvis, I am here all alone'), + }); + + return [learningObject01, learningObject02, learningObject03, learningObject04, learningObject05]; +} diff --git a/backend/tests/test_assets/content/learning-paths.testdata.ts b/backend/tests/test_assets/content/learning-paths.testdata.ts new file mode 100644 index 00000000..d2e65c9e --- /dev/null +++ b/backend/tests/test_assets/content/learning-paths.testdata.ts @@ -0,0 +1,100 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { LearningPath } from '../../../src/entities/content/learning-path.entity'; +import { Language } from '../../../src/entities/content/language'; +import { LearningPathTransition } from '../../../src/entities/content/learning-path-transition.entity'; +import { LearningPathNode } from '../../../src/entities/content/learning-path-node.entity'; + +export function makeTestLearningPaths(em: EntityManager>): Array { + const learningPathNode01: LearningPathNode = new LearningPathNode(); + const learningPathNode02: LearningPathNode = new LearningPathNode(); + const learningPathNode03: LearningPathNode = new LearningPathNode(); + const learningPathNode04: LearningPathNode = new LearningPathNode(); + const learningPathNode05: LearningPathNode = new LearningPathNode(); + + const transitions01: LearningPathTransition = new LearningPathTransition(); + const transitions02: LearningPathTransition = new LearningPathTransition(); + const transitions03: LearningPathTransition = new LearningPathTransition(); + const transitions04: LearningPathTransition = new LearningPathTransition(); + const transitions05: LearningPathTransition = new LearningPathTransition(); + + transitions01.condition = 'true'; + transitions01.next = learningPathNode02; + + transitions02.condition = 'true'; + transitions02.next = learningPathNode02; + + transitions03.condition = 'true'; + transitions03.next = learningPathNode04; + + transitions04.condition = 'true'; + transitions04.next = learningPathNode05; + + transitions05.condition = 'true'; + transitions05.next = learningPathNode05; + + learningPathNode01.instruction = ''; + learningPathNode01.language = Language.English; + learningPathNode01.learningObjectHruid = 'id01'; + learningPathNode01.startNode = true; + learningPathNode01.transitions = [transitions01]; + learningPathNode01.version = 1; + + learningPathNode02.instruction = ''; + learningPathNode02.language = Language.English; + learningPathNode02.learningObjectHruid = 'id02'; + learningPathNode02.startNode = false; + learningPathNode02.transitions = [transitions02]; + learningPathNode02.version = 1; + + learningPathNode03.instruction = ''; + learningPathNode03.language = Language.English; + learningPathNode03.learningObjectHruid = 'id03'; + learningPathNode03.startNode = true; + learningPathNode03.transitions = [transitions03]; + learningPathNode03.version = 1; + + learningPathNode04.instruction = ''; + learningPathNode04.language = Language.English; + learningPathNode04.learningObjectHruid = 'id04'; + learningPathNode04.startNode = false; + learningPathNode04.transitions = [transitions04]; + learningPathNode04.version = 1; + + learningPathNode05.instruction = ''; + learningPathNode05.language = Language.English; + learningPathNode05.learningObjectHruid = 'id05'; + learningPathNode05.startNode = false; + learningPathNode05.transitions = [transitions05]; + learningPathNode05.version = 1; + + const nodes01: Array = [ + // LearningPathNode01, + // LearningPathNode02, + ]; + const learningPath01 = em.create(LearningPath, { + hruid: 'id01', + language: Language.English, + admins: [], + title: 'repertoire Tool', + description: 'all about Tool', + image: '', + nodes: nodes01, + }); + + const nodes02: Array = [ + // LearningPathNode03, + // LearningPathNode04, + // LearningPathNode05, + ]; + const learningPath02 = em.create(LearningPath, { + hruid: 'id02', + language: Language.English, + admins: [], + title: 'repertoire Dire Straits', + description: 'all about Dire Straits', + image: '', + nodes: nodes02, + }); + + return [learningPath01, learningPath02]; +} diff --git a/backend/tests/test_assets/questions/answers.testdata.ts b/backend/tests/test_assets/questions/answers.testdata.ts new file mode 100644 index 00000000..20e816da --- /dev/null +++ b/backend/tests/test_assets/questions/answers.testdata.ts @@ -0,0 +1,32 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Answer } from '../../../src/entities/questions/answer.entity'; +import { Teacher } from '../../../src/entities/users/teacher.entity'; +import { Question } from '../../../src/entities/questions/question.entity'; + +export function makeTestAnswers(em: EntityManager>, teachers: Array, questions: Array): Array { + const answer01 = em.create(Answer, { + author: teachers[0], + toQuestion: questions[1], + sequenceNumber: 1, + timestamp: new Date(), + content: 'answer', + }); + + const answer02 = em.create(Answer, { + author: teachers[0], + toQuestion: questions[1], + sequenceNumber: 2, + timestamp: new Date(), + content: 'answer2', + }); + + const answer03 = em.create(Answer, { + author: teachers[1], + toQuestion: questions[3], + sequenceNumber: 1, + timestamp: new Date(), + content: 'answer3', + }); + + return [answer01, answer02, answer03]; +} diff --git a/backend/tests/test_assets/questions/questions.testdata.ts b/backend/tests/test_assets/questions/questions.testdata.ts new file mode 100644 index 00000000..23552152 --- /dev/null +++ b/backend/tests/test_assets/questions/questions.testdata.ts @@ -0,0 +1,48 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Question } from '../../../src/entities/questions/question.entity'; +import { Language } from '../../../src/entities/content/language'; +import { Student } from '../../../src/entities/users/student.entity'; + +export function makeTestQuestions(em: EntityManager>, students: Array): Array { + const question01 = em.create(Question, { + learningObjectLanguage: Language.English, + learningObjectVersion: '1', + learningObjectHruid: 'id05', + sequenceNumber: 1, + author: students[0], + timestamp: new Date(), + content: 'question', + }); + + const question02 = em.create(Question, { + learningObjectLanguage: Language.English, + learningObjectVersion: '1', + learningObjectHruid: 'id05', + sequenceNumber: 2, + author: students[2], + timestamp: new Date(), + content: 'question', + }); + + const question03 = em.create(Question, { + learningObjectLanguage: Language.English, + learningObjectVersion: '1', + learningObjectHruid: 'id04', + sequenceNumber: 1, + author: students[0], + timestamp: new Date(), + content: 'question', + }); + + const question04 = em.create(Question, { + learningObjectLanguage: Language.English, + learningObjectVersion: '1', + learningObjectHruid: 'id01', + sequenceNumber: 1, + author: students[1], + timestamp: new Date(), + content: 'question', + }); + + return [question01, question02, question03, question04]; +} diff --git a/backend/tests/test_assets/users/students.testdata.ts b/backend/tests/test_assets/users/students.testdata.ts new file mode 100644 index 00000000..61e0b590 --- /dev/null +++ b/backend/tests/test_assets/users/students.testdata.ts @@ -0,0 +1,49 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Student } from '../../../src/entities/users/student.entity'; + +export function makeTestStudents(em: EntityManager>): Array { + const student01 = em.create(Student, { + username: 'Noordkaap', + firstName: 'Stijn', + lastName: 'Meuris', + }); + + const student02 = em.create(Student, { + username: 'DireStraits', + firstName: 'Mark', + lastName: 'Knopfler', + }); + + const student03 = em.create(Student, { + username: 'Tool', + firstName: 'Maynard', + lastName: 'Keenan', + }); + + const student04 = em.create(Student, { + username: 'SmashingPumpkins', + firstName: 'Billy', + lastName: 'Corgan', + }); + + const student05 = em.create(Student, { + username: 'PinkFloyd', + firstName: 'David', + lastName: 'Gilmoure', + }); + + const student06 = em.create(Student, { + username: 'TheDoors', + firstName: 'Jim', + lastName: 'Morisson', + }); + + // Do not use for any tests, gets deleted in a unit test + const student07 = em.create(Student, { + username: 'Nirvana', + firstName: 'Kurt', + lastName: 'Cobain', + }); + + return [student01, student02, student03, student04, student05, student06, student07]; +} diff --git a/backend/tests/test_assets/users/teachers.testdata.ts b/backend/tests/test_assets/users/teachers.testdata.ts new file mode 100644 index 00000000..d8985e44 --- /dev/null +++ b/backend/tests/test_assets/users/teachers.testdata.ts @@ -0,0 +1,31 @@ +import { Teacher } from '../../../src/entities/users/teacher.entity'; +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; + +export function makeTestTeachers(em: EntityManager>): Array { + const teacher01 = em.create(Teacher, { + username: 'FooFighters', + firstName: 'Dave', + lastName: 'Grohl', + }); + + const teacher02 = em.create(Teacher, { + username: 'LimpBizkit', + firstName: 'Fred', + lastName: 'Durst', + }); + + const teacher03 = em.create(Teacher, { + username: 'Staind', + firstName: 'Aaron', + lastName: 'Lewis', + }); + + // Should not be used, gets deleted in a unit test + const teacher04 = em.create(Teacher, { + username: 'ZesdeMetaal', + firstName: 'Wannes', + lastName: 'Cappelle', + }); + + return [teacher01, teacher02, teacher03, teacher04]; +} diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 302015fb..461d2018 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -4,5 +4,6 @@ export default defineConfig({ test: { environment: 'node', globals: true, + testTimeout: 10000, }, }); diff --git a/eslint.config.ts b/eslint.config.ts index 6a59a583..52a36775 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -38,7 +38,9 @@ export default [ 'no-unreachable-loop': 'warn', 'no-use-before-define': 'error', 'no-useless-assignment': 'error', - 'no-unused-vars': 'error', + + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': 'error', 'arrow-body-style': ['warn', 'as-needed'], 'block-scoped-var': 'warn', diff --git a/frontend/src/components/MenuBar.vue b/frontend/src/components/MenuBar.vue index fe2b3563..7d7c4d88 100644 --- a/frontend/src/components/MenuBar.vue +++ b/frontend/src/components/MenuBar.vue @@ -2,8 +2,10 @@ import { ref } from "vue"; import { useRoute } from "vue-router"; import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg"; + import { useI18n } from "vue-i18n"; const route = useRoute(); + const { t, locale } = useI18n(); // Instantiate variables to use in html to render right // Links and content dependent on the role (student or teacher) @@ -27,6 +29,8 @@ // Logic to change the language of the website to the selected language const changeLanguage = (langCode: string) => { + locale.value = langCode; + localStorage.setItem("user-lang", langCode); console.log(langCode); }; @@ -46,7 +50,7 @@ :src="dwengoLogo" />

- {{ role }} + {{ t(`${role}`) }}

@@ -55,22 +59,22 @@ :to="`/${role}/${userId}/assignment`" class="menu_item" > - assignments + {{ t("assignments") }}
  • classes{{ t("classes") }}
  • discussions + >{{ t("discussions") }} +
  • @@ -104,7 +108,7 @@