Merge branch 'dev' into feat/service-layer
This commit is contained in:
		
						commit
						e487dfff5c
					
				
					 20 changed files with 647 additions and 74 deletions
				
			
		|  | @ -1,10 +1,16 @@ | |||
| DWENGO_PORT=3000 | ||||
| # | ||||
| # Basic configuration | ||||
| # | ||||
| 
 | ||||
| DWENGO_PORT=3000 # The port the backend will listen on | ||||
| DWENGO_DB_HOST=localhost | ||||
| DWENGO_DB_PORT=5431 | ||||
| DWENGO_DB_USERNAME=postgres | ||||
| DWENGO_DB_PASSWORD=postgres | ||||
| DWENGO_DB_UPDATE=true | ||||
| 
 | ||||
| # Auth | ||||
| 
 | ||||
| DWENGO_AUTH_STUDENT_URL=http://localhost:7080/realms/student | ||||
| DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo | ||||
| DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://localhost:7080/realms/student/protocol/openid-connect/certs | ||||
|  | @ -14,3 +20,9 @@ DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://localhost:7080/realms/teacher/protocol/ | |||
| 
 | ||||
| # Allow Vite dev-server to access the backend (for testing purposes). Don't forget to remove this in production! | ||||
| DWENGO_CORS_ALLOWED_ORIGINS=http://localhost:5173 | ||||
| 
 | ||||
| # | ||||
| # Advanced configuration | ||||
| # | ||||
| 
 | ||||
| # LOKI_HOST=http://localhost:9001      # The address of the Loki instance, used for logging | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| DWENGO_PORT=3000    # The port the backend will listen on | ||||
| DWENGO_PORT=3000 # The port the backend will listen on | ||||
| DWENGO_DB_HOST=domain-or-ip-of-database | ||||
| DWENGO_DB_PORT=5432 | ||||
| DWENGO_DB_PORT=5431 | ||||
| 
 | ||||
| # Change this to the actual credentials of the user Dwengo should use in the backend | ||||
| DWENGO_DB_USERNAME=postgres | ||||
|  |  | |||
							
								
								
									
										28
									
								
								backend/.env.production.example
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								backend/.env.production.example
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | |||
| DWENGO_PORT=3000 # The port the backend will listen on | ||||
| DWENGO_DB_HOST=db # Name of the database container | ||||
| DWENGO_DB_PORT=5431 | ||||
| 
 | ||||
| # Change this to the actual credentials of the user Dwengo should use in the backend | ||||
| DWENGO_DB_NAME=postgres | ||||
| DWENGO_DB_USERNAME=postgres | ||||
| DWENGO_DB_PASSWORD=postgres | ||||
| 
 | ||||
| # Set this to true when the database scheme needs to be updated. In that case, take a backup first. | ||||
| DWENGO_DB_UPDATE=false | ||||
| 
 | ||||
| # Data for the identity provider via which the students authenticate. | ||||
| DWENGO_AUTH_STUDENT_URL=https://sel2-1.ugent.be/idp/realms/student | ||||
| DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo | ||||
| DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://idp:7080/idp/realms/student/protocol/openid-connect/certs # Name of the idp container | ||||
| # Data for the identity provider via which the teachers authenticate. | ||||
| DWENGO_AUTH_TEACHER_URL=https://sel2-1.ugent.be/idp/realms/teacher | ||||
| DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo | ||||
| DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://idp:7080/idp/realms/teacher/protocol/openid-connect/certs # Name of the idp container | ||||
| 
 | ||||
| # | ||||
| # Advanced configuration | ||||
| # | ||||
| 
 | ||||
| # Logging and monitoring | ||||
| 
 | ||||
| # LOKI_HOST=http://logging:3102 # The address of the Loki instance, used for logging | ||||
							
								
								
									
										35
									
								
								backend/Dockerfile
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								backend/Dockerfile
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,35 @@ | |||
| FROM node:22 AS build-stage | ||||
| 
 | ||||
| WORKDIR /app | ||||
| 
 | ||||
| # Install dependencies | ||||
| 
 | ||||
| COPY package*.json ./ | ||||
| COPY backend/package.json ./backend/ | ||||
| 
 | ||||
| RUN npm install --silent | ||||
| 
 | ||||
| # Build the backend | ||||
| 
 | ||||
| # Root tsconfig.json | ||||
| COPY tsconfig.json ./ | ||||
| 
 | ||||
| WORKDIR /app/backend | ||||
| 
 | ||||
| COPY backend ./ | ||||
| 
 | ||||
| RUN npm run build | ||||
| 
 | ||||
| FROM node:22 AS production-stage | ||||
| 
 | ||||
| WORKDIR /app | ||||
| 
 | ||||
| COPY package-lock.json backend/package.json ./ | ||||
| 
 | ||||
| RUN npm install --silent --only=production | ||||
| 
 | ||||
| COPY --from=build-stage /app/backend/dist ./dist/ | ||||
| 
 | ||||
| EXPOSE 3000 | ||||
| 
 | ||||
| CMD ["node", "--env-file=.env", "dist/app.js"] | ||||
|  | @ -1,4 +1,4 @@ | |||
| import express, { Express, Response } from 'express'; | ||||
| import express, { Express } from 'express'; | ||||
| import { initORM } from './orm.js'; | ||||
| 
 | ||||
| import themeRoutes from './routes/themes.js'; | ||||
|  | @ -13,12 +13,14 @@ import submissionRouter from './routes/submissions.js'; | |||
| import classRouter from './routes/classes.js'; | ||||
| import questionRouter from './routes/questions.js'; | ||||
| import authRouter from './routes/auth.js'; | ||||
| 
 | ||||
| import { authenticateUser } from './middleware/auth/auth.js'; | ||||
| import cors from './middleware/cors.js'; | ||||
| import { getLogger, Logger } from './logging/initalize.js'; | ||||
| import { responseTimeLogger } from './logging/responseTimeLogger.js'; | ||||
| import responseTime from 'response-time'; | ||||
| import { EnvVars, getNumericEnvVar } from './util/envvars.js'; | ||||
| import apiRouter from './routes/router.js'; | ||||
| 
 | ||||
| const logger: Logger = getLogger(); | ||||
| 
 | ||||
|  | @ -30,31 +32,13 @@ app.use(express.json()); | |||
| app.use(responseTime(responseTimeLogger)); | ||||
| app.use(authenticateUser); | ||||
| 
 | ||||
| // TODO Replace with Express routes
 | ||||
| app.get('/', (_, res: Response) => { | ||||
|     logger.debug('GET /'); | ||||
|     res.json({ | ||||
|         message: 'Hello Dwengo!🚀', | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| app.use('/student', studentRouter); | ||||
| app.use('/teacher', teacherRouter); | ||||
| app.use('/group', groupRouter); | ||||
| app.use('/assignment', assignmentRouter); | ||||
| app.use('/submission', submissionRouter); | ||||
| app.use('/class', classRouter); | ||||
| app.use('/question', questionRouter); | ||||
| app.use('/auth', authRouter); | ||||
| app.use('/theme', themeRoutes); | ||||
| app.use('/learningPath', learningPathRoutes); | ||||
| app.use('/learningObject', learningObjectRoutes); | ||||
| app.get('/api', apiRouter); | ||||
| 
 | ||||
| async function startServer() { | ||||
|     await initORM(); | ||||
| 
 | ||||
|     app.listen(port, () => { | ||||
|         logger.info(`Server is running at http://localhost:${port}`); | ||||
|         logger.info(`Server is running at http://localhost:${port}/api`); | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										35
									
								
								backend/src/routes/router.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								backend/src/routes/router.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,35 @@ | |||
| import { Response, Router } from 'express'; | ||||
| import studentRouter from './student'; | ||||
| import groupRouter from './group'; | ||||
| import assignmentRouter from './assignment'; | ||||
| import submissionRouter from './submission'; | ||||
| import classRouter from './class'; | ||||
| import questionRouter from './question'; | ||||
| import authRouter from './auth'; | ||||
| import themeRoutes from './themes'; | ||||
| import learningPathRoutes from './learning-paths'; | ||||
| import learningObjectRoutes from './learning-objects'; | ||||
| import { getLogger, Logger } from '../logging/initalize'; | ||||
| 
 | ||||
| const router = Router(); | ||||
| const logger: Logger = getLogger(); | ||||
| 
 | ||||
| router.get('/', (_, res: Response) => { | ||||
|     logger.debug('GET /'); | ||||
|     res.json({ | ||||
|         message: 'Hello Dwengo!🚀', | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| router.use('/student', studentRouter); | ||||
| router.use('/group', groupRouter); | ||||
| router.use('/assignment', assignmentRouter); | ||||
| router.use('/submission', submissionRouter); | ||||
| router.use('/class', classRouter); | ||||
| router.use('/question', questionRouter); | ||||
| router.use('/auth', authRouter); | ||||
| router.use('/theme', themeRoutes); | ||||
| router.use('/learningPath', learningPathRoutes); | ||||
| router.use('/learningObject', learningObjectRoutes); | ||||
| 
 | ||||
| export default router; | ||||
							
								
								
									
										117
									
								
								backend/tests/service/learning-objects.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								backend/tests/service/learning-objects.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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([]); | ||||
|     }); | ||||
| }); | ||||
							
								
								
									
										90
									
								
								backend/tests/service/learning-paths.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								backend/tests/service/learning-paths.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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([]); | ||||
|     }); | ||||
| }); | ||||
		Reference in a new issue
	
	 Laure Jablonski
						Laure Jablonski