From d7ae60f0ac171115b4cf1731daf847864a9b079c Mon Sep 17 00:00:00 2001 From: Thomas Feldtkeller Date: Mon, 20 Nov 2023 13:07:41 +0100 Subject: [PATCH 1/2] BC-5803 lesson refactorings (#4562) * isolate lesson repo * fix missing module imports * move lesson repo in lesson module --- .../authorization-reference.module.ts | 19 +++++--- .../authorization/authorization.module.ts | 10 ++--- .../domain/service/reference.loader.spec.ts | 20 ++++----- .../domain/service/reference.loader.ts | 16 +++---- .../server/src/modules/authorization/index.ts | 14 +++--- .../src/modules/files-storage-client/index.ts | 2 +- apps/server/src/modules/learnroom/index.ts | 3 +- .../modules/learnroom/learnroom-api.module.ts | 5 +-- .../src/modules/learnroom/learnroom.module.ts | 7 ++- .../learnroom/service/rooms.service.spec.ts | 19 ++++---- .../learnroom/service/rooms.service.ts | 11 ++--- .../modules/learnroom/uc/course.uc.spec.ts | 6 +-- .../learnroom/uc/lesson-copy.uc.spec.ts | 36 +++++++-------- .../modules/learnroom/uc/lesson-copy.uc.ts | 15 +++---- .../src/modules/learnroom/uc/rooms.uc.spec.ts | 10 +---- apps/server/src/modules/lesson/index.ts | 2 + .../src/modules/lesson/lesson.module.ts | 6 +-- .../lesson/repository}/index.ts | 0 .../lesson/repository}/lesson-scope.ts | 2 +- .../lesson.repo.integration.spec.ts | 0 .../lesson/repository}/lesson.repo.ts | 4 +- .../service/lesson-copy.service.spec.ts | 8 ++-- .../lesson/service/lesson-copy.service.ts | 9 ++-- .../lesson/service/lesson.service.spec.ts | 24 +++++++--- .../modules/lesson/service/lesson.service.ts | 11 ++--- .../modules/sharing/uc/share-token.uc.spec.ts | 25 +++++------ apps/server/src/modules/task/index.ts | 3 ++ .../src/modules/task/task-api.module.ts | 9 ++-- apps/server/src/modules/task/task.module.ts | 4 +- .../src/modules/task/uc/task-copy.uc.spec.ts | 27 +++++------ .../src/modules/task/uc/task-copy.uc.ts | 11 ++--- .../src/modules/task/uc/task.uc.spec.ts | 45 ++++++++++--------- apps/server/src/modules/task/uc/task.uc.ts | 11 ++--- apps/server/src/modules/tool/index.ts | 5 ++- apps/server/src/shared/repo/index.ts | 1 - 35 files changed, 202 insertions(+), 198 deletions(-) rename apps/server/src/{shared/repo/lesson => modules/lesson/repository}/index.ts (100%) rename apps/server/src/{shared/repo/lesson => modules/lesson/repository}/lesson-scope.ts (90%) rename apps/server/src/{shared/repo/lesson => modules/lesson/repository}/lesson.repo.integration.spec.ts (100%) rename apps/server/src/{shared/repo/lesson => modules/lesson/repository}/lesson.repo.ts (97%) diff --git a/apps/server/src/modules/authorization/authorization-reference.module.ts b/apps/server/src/modules/authorization/authorization-reference.module.ts index e253587af7b..a115d0433c3 100644 --- a/apps/server/src/modules/authorization/authorization-reference.module.ts +++ b/apps/server/src/modules/authorization/authorization-reference.module.ts @@ -1,20 +1,20 @@ +import { BoardModule } from '@modules/board'; +import { ToolModule } from '@modules/tool'; import { forwardRef, Module } from '@nestjs/common'; import { CourseGroupRepo, CourseRepo, - LessonRepo, - SchoolExternalToolRepo, LegacySchoolRepo, + SchoolExternalToolRepo, SubmissionRepo, TaskRepo, TeamsRepo, UserRepo, } from '@shared/repo'; -import { ToolModule } from '@modules/tool'; import { LoggerModule } from '@src/core/logger'; -import { BoardModule } from '@modules/board'; -import { ReferenceLoader, AuthorizationReferenceService, AuthorizationHelper } from './domain'; +import { LessonModule } from '../lesson'; import { AuthorizationModule } from './authorization.module'; +import { AuthorizationHelper, AuthorizationReferenceService, ReferenceLoader } from './domain'; /** * This module is part of an intermediate state. In the future it should be replaced by an AuthorizationApiModule. @@ -23,7 +23,13 @@ import { AuthorizationModule } from './authorization.module'; */ @Module({ // TODO: remove forwardRef to TooModule N21-1055 - imports: [AuthorizationModule, forwardRef(() => ToolModule), forwardRef(() => BoardModule), LoggerModule], + imports: [ + AuthorizationModule, + LessonModule, + forwardRef(() => ToolModule), + forwardRef(() => BoardModule), + LoggerModule, + ], providers: [ AuthorizationHelper, ReferenceLoader, @@ -32,7 +38,6 @@ import { AuthorizationModule } from './authorization.module'; CourseGroupRepo, TaskRepo, LegacySchoolRepo, - LessonRepo, TeamsRepo, SubmissionRepo, SchoolExternalToolRepo, diff --git a/apps/server/src/modules/authorization/authorization.module.ts b/apps/server/src/modules/authorization/authorization.module.ts index d01cd9363f4..f734a72ed8a 100644 --- a/apps/server/src/modules/authorization/authorization.module.ts +++ b/apps/server/src/modules/authorization/authorization.module.ts @@ -1,23 +1,23 @@ +import { FeathersModule } from '@infra/feathers'; import { Module } from '@nestjs/common'; import { UserRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; -import { FeathersModule } from '@infra/feathers'; +import { AuthorizationHelper, AuthorizationService, RuleManager } from './domain'; import { BoardDoRule, ContextExternalToolRule, CourseGroupRule, CourseRule, + GroupRule, + LegacySchoolRule, LessonRule, SchoolExternalToolRule, SubmissionRule, TaskRule, TeamRule, - UserRule, UserLoginMigrationRule, - LegacySchoolRule, - GroupRule, + UserRule, } from './domain/rules'; -import { AuthorizationHelper, AuthorizationService, RuleManager } from './domain'; import { FeathersAuthorizationService, FeathersAuthProvider } from './feathers'; @Module({ diff --git a/apps/server/src/modules/authorization/domain/service/reference.loader.spec.ts b/apps/server/src/modules/authorization/domain/service/reference.loader.spec.ts index e2e77212cab..11f553423b6 100644 --- a/apps/server/src/modules/authorization/domain/service/reference.loader.spec.ts +++ b/apps/server/src/modules/authorization/domain/service/reference.loader.spec.ts @@ -1,24 +1,24 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; +import { BoardDoAuthorizableService } from '@modules/board'; +import { ContextExternalToolAuthorizableService } from '@modules/tool'; import { NotImplementedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityId } from '@shared/domain'; import { CourseGroupRepo, CourseRepo, - LessonRepo, - SchoolExternalToolRepo, LegacySchoolRepo, + SchoolExternalToolRepo, SubmissionRepo, TaskRepo, TeamsRepo, UserRepo, } from '@shared/repo'; import { setupEntities, userFactory } from '@shared/testing'; -import { BoardDoAuthorizableService } from '@modules/board'; -import { ContextExternalToolAuthorizableService } from '@modules/tool/context-external-tool/service/context-external-tool-authorizable.service'; -import { ReferenceLoader } from './reference.loader'; +import { LessonService } from '@modules/lesson'; import { AuthorizableReferenceType } from '../type'; +import { ReferenceLoader } from './reference.loader'; describe('reference.loader', () => { let service: ReferenceLoader; @@ -27,7 +27,7 @@ describe('reference.loader', () => { let courseGroupRepo: DeepMocked; let taskRepo: DeepMocked; let schoolRepo: DeepMocked; - let lessonRepo: DeepMocked; + let lessonService: DeepMocked; let teamsRepo: DeepMocked; let submissionRepo: DeepMocked; let schoolExternalToolRepo: DeepMocked; @@ -62,8 +62,8 @@ describe('reference.loader', () => { useValue: createMock(), }, { - provide: LessonRepo, - useValue: createMock(), + provide: LessonService, + useValue: createMock(), }, { provide: TeamsRepo, @@ -94,7 +94,7 @@ describe('reference.loader', () => { courseGroupRepo = await module.get(CourseGroupRepo); taskRepo = await module.get(TaskRepo); schoolRepo = await module.get(LegacySchoolRepo); - lessonRepo = await module.get(LessonRepo); + lessonService = await module.get(LessonService); teamsRepo = await module.get(TeamsRepo); submissionRepo = await module.get(SubmissionRepo); schoolExternalToolRepo = await module.get(SchoolExternalToolRepo); @@ -144,7 +144,7 @@ describe('reference.loader', () => { it('should call lessonRepo.findById', async () => { await service.loadAuthorizableObject(AuthorizableReferenceType.Lesson, entityId); - expect(lessonRepo.findById).toBeCalledWith(entityId); + expect(lessonService.findById).toBeCalledWith(entityId); }); it('should call teamsRepo.findById', async () => { diff --git a/apps/server/src/modules/authorization/domain/service/reference.loader.ts b/apps/server/src/modules/authorization/domain/service/reference.loader.ts index d584561be9e..c0ea22212a0 100644 --- a/apps/server/src/modules/authorization/domain/service/reference.loader.ts +++ b/apps/server/src/modules/authorization/domain/service/reference.loader.ts @@ -1,19 +1,19 @@ +import { BoardDoAuthorizableService } from '@modules/board'; +import { ContextExternalToolAuthorizableService } from '@modules/tool'; import { Injectable, NotImplementedException } from '@nestjs/common'; import { BaseDO, EntityId } from '@shared/domain'; import { AuthorizableObject } from '@shared/domain/domain-object'; import { CourseGroupRepo, CourseRepo, - LessonRepo, - SchoolExternalToolRepo, LegacySchoolRepo, + SchoolExternalToolRepo, SubmissionRepo, TaskRepo, TeamsRepo, UserRepo, } from '@shared/repo'; -import { BoardDoAuthorizableService } from '@modules/board'; -import { ContextExternalToolAuthorizableService } from '@modules/tool/context-external-tool/service'; +import { LessonService } from '@modules/lesson'; import { AuthorizableReferenceType } from '../type'; type RepoType = @@ -21,13 +21,13 @@ type RepoType = | CourseRepo | UserRepo | LegacySchoolRepo - | LessonRepo | TeamsRepo | CourseGroupRepo | SubmissionRepo | SchoolExternalToolRepo | BoardDoAuthorizableService - | ContextExternalToolAuthorizableService; + | ContextExternalToolAuthorizableService + | LessonService; interface IRepoLoader { repo: RepoType; @@ -44,7 +44,7 @@ export class ReferenceLoader { private readonly courseGroupRepo: CourseGroupRepo, private readonly taskRepo: TaskRepo, private readonly schoolRepo: LegacySchoolRepo, - private readonly lessonRepo: LessonRepo, + private readonly lessonService: LessonService, private readonly teamsRepo: TeamsRepo, private readonly submissionRepo: SubmissionRepo, private readonly schoolExternalToolRepo: SchoolExternalToolRepo, @@ -56,7 +56,7 @@ export class ReferenceLoader { this.repos.set(AuthorizableReferenceType.CourseGroup, { repo: this.courseGroupRepo }); this.repos.set(AuthorizableReferenceType.User, { repo: this.userRepo }); this.repos.set(AuthorizableReferenceType.School, { repo: this.schoolRepo }); - this.repos.set(AuthorizableReferenceType.Lesson, { repo: this.lessonRepo }); + this.repos.set(AuthorizableReferenceType.Lesson, { repo: this.lessonService }); this.repos.set(AuthorizableReferenceType.Team, { repo: this.teamsRepo, populate: true }); this.repos.set(AuthorizableReferenceType.Submission, { repo: this.submissionRepo }); this.repos.set(AuthorizableReferenceType.SchoolExternalToolEntity, { repo: this.schoolExternalToolRepo }); diff --git a/apps/server/src/modules/authorization/index.ts b/apps/server/src/modules/authorization/index.ts index e129df2cd11..13ae209f13d 100644 --- a/apps/server/src/modules/authorization/index.ts +++ b/apps/server/src/modules/authorization/index.ts @@ -1,15 +1,17 @@ export { AuthorizationModule } from './authorization.module'; export { - AuthorizationService, - AuthorizationHelper, - AuthorizationContextBuilder, - ForbiddenLoggableException, - Rule, - AuthorizationContext, // Action should not be exported, but hard to solve for now. The AuthorizationContextBuilder is the prefared way Action, + AuthorizableReferenceType, + AuthorizationContext, + AuthorizationContextBuilder, + AuthorizationHelper, AuthorizationLoaderService, AuthorizationLoaderServiceGeneric, + AuthorizationReferenceService, + AuthorizationService, + ForbiddenLoggableException, + Rule, } from './domain'; // Should not used anymore export { FeathersAuthorizationService } from './feathers'; diff --git a/apps/server/src/modules/files-storage-client/index.ts b/apps/server/src/modules/files-storage-client/index.ts index 7b242946d8f..19248d129de 100644 --- a/apps/server/src/modules/files-storage-client/index.ts +++ b/apps/server/src/modules/files-storage-client/index.ts @@ -2,5 +2,5 @@ export { FileDto } from './dto'; export * from './files-storage-client.module'; export { IFilesStorageClientConfig } from './interfaces'; export { FileParamBuilder } from './mapper/files-storage-param.builder'; -export { CopyFilesService } from './service/copy-files.service'; +export * from './service/copy-files.service'; export { FilesStorageClientAdapterService } from './service/files-storage-client.service'; diff --git a/apps/server/src/modules/learnroom/index.ts b/apps/server/src/modules/learnroom/index.ts index e4d907784d5..9fe9c100886 100644 --- a/apps/server/src/modules/learnroom/index.ts +++ b/apps/server/src/modules/learnroom/index.ts @@ -1,3 +1,2 @@ export * from './learnroom.module'; -export * from './service/course-copy.service'; -export { CourseService } from './service'; +export { CommonCartridgeExportService, CourseCopyService, CourseService, RoomsService } from './service'; diff --git a/apps/server/src/modules/learnroom/learnroom-api.module.ts b/apps/server/src/modules/learnroom/learnroom-api.module.ts index 5cfaada65b8..a2a407daf21 100644 --- a/apps/server/src/modules/learnroom/learnroom-api.module.ts +++ b/apps/server/src/modules/learnroom/learnroom-api.module.ts @@ -1,9 +1,9 @@ -import { Module } from '@nestjs/common'; -import { BoardRepo, CourseRepo, DashboardModelMapper, DashboardRepo, LessonRepo, UserRepo } from '@shared/repo'; import { AuthorizationModule } from '@modules/authorization'; import { AuthorizationReferenceModule } from '@modules/authorization/authorization-reference.module'; import { CopyHelperModule } from '@modules/copy-helper'; import { LessonModule } from '@modules/lesson'; +import { Module } from '@nestjs/common'; +import { BoardRepo, CourseRepo, DashboardModelMapper, DashboardRepo, UserRepo } from '@shared/repo'; import { CourseController } from './controller/course.controller'; import { DashboardController } from './controller/dashboard.controller'; import { RoomsController } from './controller/rooms.controller'; @@ -42,7 +42,6 @@ import { CourseRepo, UserRepo, BoardRepo, - LessonRepo, ], }) export class LearnroomApiModule {} diff --git a/apps/server/src/modules/learnroom/learnroom.module.ts b/apps/server/src/modules/learnroom/learnroom.module.ts index c84310ba05e..02071369766 100644 --- a/apps/server/src/modules/learnroom/learnroom.module.ts +++ b/apps/server/src/modules/learnroom/learnroom.module.ts @@ -1,10 +1,10 @@ -import { Module } from '@nestjs/common'; -import { BoardRepo, CourseRepo, DashboardModelMapper, DashboardRepo, LessonRepo, UserRepo } from '@shared/repo'; -import { LoggerModule } from '@src/core/logger'; import { BoardModule } from '@modules/board'; import { CopyHelperModule } from '@modules/copy-helper'; import { LessonModule } from '@modules/lesson'; import { TaskModule } from '@modules/task'; +import { Module } from '@nestjs/common'; +import { BoardRepo, CourseRepo, DashboardModelMapper, DashboardRepo, UserRepo } from '@shared/repo'; +import { LoggerModule } from '@src/core/logger'; import { BoardCopyService, ColumnBoardTargetService, @@ -23,7 +23,6 @@ import { }, DashboardModelMapper, CourseRepo, - LessonRepo, BoardRepo, UserRepo, BoardCopyService, diff --git a/apps/server/src/modules/learnroom/service/rooms.service.spec.ts b/apps/server/src/modules/learnroom/service/rooms.service.spec.ts index 2358e2b2067..ef161ce179a 100644 --- a/apps/server/src/modules/learnroom/service/rooms.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/rooms.service.spec.ts @@ -2,19 +2,20 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { IConfig } from '@hpi-schul-cloud/commons/lib/interfaces/IConfig'; import { ObjectId } from '@mikro-orm/mongodb'; +import { CardService, ColumnBoardService, ColumnService, ContentElementService } from '@modules/board'; +import { LessonService } from '@modules/lesson'; +import { TaskService } from '@modules/task'; import { Test, TestingModule } from '@nestjs/testing'; import { BoardExternalReference, BoardExternalReferenceType, EntityId } from '@shared/domain'; -import { BoardRepo, LessonRepo } from '@shared/repo'; +import { BoardRepo } from '@shared/repo'; import { boardFactory, courseFactory, lessonFactory, setupEntities, taskFactory, userFactory } from '@shared/testing'; -import { CardService, ColumnBoardService, ColumnService, ContentElementService } from '@modules/board'; -import { TaskService } from '@modules/task/service'; import { ColumnBoardTargetService } from './column-board-target.service'; import { RoomsService } from './rooms.service'; describe('rooms service', () => { let module: TestingModule; let roomsService: RoomsService; - let lessonRepo: DeepMocked; + let lessonService: DeepMocked; let taskService: DeepMocked; let boardRepo: DeepMocked; let columnBoardService: DeepMocked; @@ -32,8 +33,8 @@ describe('rooms service', () => { providers: [ RoomsService, { - provide: LessonRepo, - useValue: createMock(), + provide: LessonService, + useValue: createMock(), }, { provide: TaskService, @@ -66,7 +67,7 @@ describe('rooms service', () => { ], }).compile(); roomsService = module.get(RoomsService); - lessonRepo = module.get(LessonRepo); + lessonService = module.get(LessonService); taskService = module.get(TaskService); boardRepo = module.get(BoardRepo); columnBoardService = module.get(ColumnBoardService); @@ -90,7 +91,7 @@ describe('rooms service', () => { board.syncBoardElementReferences([...tasks, ...lessons]); const tasksSpy = taskService.findBySingleParent.mockResolvedValue([tasks, 3]); - const lessonsSpy = lessonRepo.findAllByCourseIds.mockResolvedValue([lessons, 3]); + const lessonsSpy = lessonService.findByCourseIds.mockResolvedValue([lessons, 3]); const syncBoardElementReferencesSpy = jest.spyOn(board, 'syncBoardElementReferences'); const saveSpy = boardRepo.save.mockResolvedValue(); @@ -134,7 +135,7 @@ describe('rooms service', () => { describe('for column boards', () => { const setup = () => { - lessonRepo.findAllByCourseIds.mockResolvedValueOnce([[], 0]); + lessonService.findByCourseIds.mockResolvedValueOnce([[], 0]); taskService.findBySingleParent.mockResolvedValueOnce([[], 0]); const user = userFactory.buildWithId(); diff --git a/apps/server/src/modules/learnroom/service/rooms.service.ts b/apps/server/src/modules/learnroom/service/rooms.service.ts index cc8b95e09b0..19ddfd57be8 100644 --- a/apps/server/src/modules/learnroom/service/rooms.service.ts +++ b/apps/server/src/modules/learnroom/service/rooms.service.ts @@ -1,23 +1,24 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { ColumnBoardService } from '@modules/board'; +import { LessonService } from '@modules/lesson'; +import { TaskService } from '@modules/task'; import { Injectable } from '@nestjs/common'; import { Board, BoardExternalReferenceType, ColumnBoardTarget, EntityId } from '@shared/domain'; -import { BoardRepo, LessonRepo } from '@shared/repo'; -import { ColumnBoardService } from '@modules/board'; -import { TaskService } from '@modules/task/service'; +import { BoardRepo } from '@shared/repo'; import { ColumnBoardTargetService } from './column-board-target.service'; @Injectable() export class RoomsService { constructor( private readonly taskService: TaskService, - private readonly lessonRepo: LessonRepo, + private readonly lessonService: LessonService, private readonly boardRepo: BoardRepo, private readonly columnBoardService: ColumnBoardService, private readonly columnBoardTargetService: ColumnBoardTargetService ) {} async updateBoard(board: Board, roomId: EntityId, userId: EntityId): Promise { - const [courseLessons] = await this.lessonRepo.findAllByCourseIds([roomId]); + const [courseLessons] = await this.lessonService.findByCourseIds([roomId]); const [courseTasks] = await this.taskService.findBySingleParent(userId, roomId); const courseColumnBoardTargets = await this.handleColumnBoardIntegration(roomId); diff --git a/apps/server/src/modules/learnroom/uc/course.uc.spec.ts b/apps/server/src/modules/learnroom/uc/course.uc.spec.ts index 750c9b1a287..3ab86b82c65 100644 --- a/apps/server/src/modules/learnroom/uc/course.uc.spec.ts +++ b/apps/server/src/modules/learnroom/uc/course.uc.spec.ts @@ -1,7 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { SortOrder } from '@shared/domain'; -import { CourseRepo, LessonRepo } from '@shared/repo'; +import { CourseRepo } from '@shared/repo'; import { courseFactory, setupEntities } from '@shared/testing'; import { CourseUc } from './course.uc'; @@ -19,10 +19,6 @@ describe('CourseUc', () => { provide: CourseRepo, useValue: createMock(), }, - { - provide: LessonRepo, - useValue: createMock(), - }, ], }).compile(); diff --git a/apps/server/src/modules/learnroom/uc/lesson-copy.uc.spec.ts b/apps/server/src/modules/learnroom/uc/lesson-copy.uc.spec.ts index 49c7c53435c..6f148abc94f 100644 --- a/apps/server/src/modules/learnroom/uc/lesson-copy.uc.spec.ts +++ b/apps/server/src/modules/learnroom/uc/lesson-copy.uc.spec.ts @@ -1,21 +1,21 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons'; import { ObjectId } from '@mikro-orm/mongodb'; +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { CopyElementType, CopyHelperService, CopyStatusEnum } from '@modules/copy-helper'; +import { LessonCopyService, LessonService } from '@modules/lesson'; import { ForbiddenException, InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain'; -import { CourseRepo, LessonRepo, UserRepo } from '@shared/repo'; +import { CourseRepo, UserRepo } from '@shared/repo'; import { courseFactory, lessonFactory, setupEntities, userFactory } from '@shared/testing'; -import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; -import { CopyElementType, CopyHelperService, CopyStatusEnum } from '@modules/copy-helper'; -import { EtherpadService, LessonCopyService } from '@modules/lesson/service'; import { LessonCopyUC } from './lesson-copy.uc'; describe('lesson copy uc', () => { let module: TestingModule; let uc: LessonCopyUC; let userRepo: DeepMocked; - let lessonRepo: DeepMocked; + let lessonService: DeepMocked; let courseRepo: DeepMocked; let authorisation: DeepMocked; let lessonCopyService: DeepMocked; @@ -35,8 +35,8 @@ describe('lesson copy uc', () => { useValue: createMock(), }, { - provide: LessonRepo, - useValue: createMock(), + provide: LessonService, + useValue: createMock(), }, { provide: CourseRepo, @@ -54,16 +54,12 @@ describe('lesson copy uc', () => { provide: CopyHelperService, useValue: createMock(), }, - { - provide: EtherpadService, - useValue: createMock(), - }, ], }).compile(); uc = module.get(LessonCopyUC); userRepo = module.get(UserRepo); - lessonRepo = module.get(LessonRepo); + lessonService = module.get(LessonService); authorisation = module.get(AuthorizationService); courseRepo = module.get(CourseRepo); lessonCopyService = module.get(LessonCopyService); @@ -128,8 +124,8 @@ describe('lesson copy uc', () => { authorisation.getUserWithPermissions.mockResolvedValueOnce(user); authorisation.hasPermission.mockReturnValue(true); - lessonRepo.findById.mockResolvedValueOnce(lesson); - lessonRepo.findAllByCourseIds.mockResolvedValueOnce([allLessons, allLessons.length]); + lessonService.findById.mockResolvedValueOnce(lesson); + lessonService.findByCourseIds.mockResolvedValueOnce([allLessons, allLessons.length]); courseRepo.findById.mockResolvedValueOnce(course); lessonCopyService.copyLesson.mockResolvedValueOnce(status); @@ -185,8 +181,8 @@ describe('lesson copy uc', () => { authorisation.getUserWithPermissions.mockResolvedValueOnce(user); authorisation.hasPermission.mockReturnValue(true); - lessonRepo.findById.mockResolvedValueOnce(lesson); - lessonRepo.findAllByCourseIds.mockResolvedValueOnce([allLessons, allLessons.length]); + lessonService.findById.mockResolvedValueOnce(lesson); + lessonService.findByCourseIds.mockResolvedValueOnce([allLessons, allLessons.length]); courseRepo.findById.mockResolvedValueOnce(course); lessonCopyService.copyLesson.mockResolvedValueOnce(status); @@ -221,7 +217,7 @@ describe('lesson copy uc', () => { await uc.copyLesson(userId, lessonId, parentParams); - expect(lessonRepo.findById).toBeCalledWith(lessonId); + expect(lessonService.findById).toBeCalledWith(lessonId); }); it('should fetch destination course', async () => { @@ -285,7 +281,7 @@ describe('lesson copy uc', () => { await uc.copyLesson(userId, lessonId, parentParams); - expect(lessonRepo.findAllByCourseIds).toHaveBeenCalledWith([courseId]); + expect(lessonService.findByCourseIds).toHaveBeenCalledWith([courseId]); }); }); @@ -300,7 +296,7 @@ describe('lesson copy uc', () => { const parentParams = { courseId: course.id, userId: new ObjectId().toHexString() }; userRepo.findById.mockResolvedValueOnce(user); - lessonRepo.findById.mockResolvedValueOnce(lesson); + lessonService.findById.mockResolvedValueOnce(lesson); courseRepo.findById.mockResolvedValueOnce(course); authorisation.hasPermission.mockReturnValueOnce(false); @@ -331,7 +327,7 @@ describe('lesson copy uc', () => { const parentParams = { courseId: course.id, userId: new ObjectId().toHexString() }; userRepo.findById.mockResolvedValueOnce(user); - lessonRepo.findById.mockResolvedValueOnce(lesson); + lessonService.findById.mockResolvedValueOnce(lesson); courseRepo.findById.mockResolvedValueOnce(course); authorisation.checkPermission.mockImplementationOnce(() => { throw new ForbiddenException(); diff --git a/apps/server/src/modules/learnroom/uc/lesson-copy.uc.ts b/apps/server/src/modules/learnroom/uc/lesson-copy.uc.ts index ae41fb6881b..c5c75a6e011 100644 --- a/apps/server/src/modules/learnroom/uc/lesson-copy.uc.ts +++ b/apps/server/src/modules/learnroom/uc/lesson-copy.uc.ts @@ -1,19 +1,18 @@ import { Configuration } from '@hpi-schul-cloud/commons'; +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { CopyHelperService, CopyStatus } from '@modules/copy-helper'; +import { LessonCopyParentParams, LessonCopyService, LessonService } from '@modules/lesson'; import { ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common'; import { Course, EntityId, LessonEntity, User } from '@shared/domain'; import { Permission } from '@shared/domain/interface/permission.enum'; -import { CourseRepo, LessonRepo } from '@shared/repo'; -import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; -import { CopyHelperService, CopyStatus } from '@modules/copy-helper'; -import { LessonCopyParentParams } from '@modules/lesson'; -import { LessonCopyService } from '@modules/lesson/service'; +import { CourseRepo } from '@shared/repo'; @Injectable() export class LessonCopyUC { constructor( private readonly authorisation: AuthorizationService, private readonly lessonCopyService: LessonCopyService, - private readonly lessonRepo: LessonRepo, + private readonly lessonService: LessonService, private readonly courseRepo: CourseRepo, private readonly copyHelperService: CopyHelperService ) {} @@ -23,7 +22,7 @@ export class LessonCopyUC { const [user, originalLesson]: [User, LessonEntity] = await Promise.all([ this.authorisation.getUserWithPermissions(userId), - this.lessonRepo.findById(lessonId), + this.lessonService.findById(lessonId), ]); this.checkOriginalLessonAuthorization(user, originalLesson); @@ -37,7 +36,7 @@ export class LessonCopyUC { this.checkDestinationCourseAuthorization(user, destinationCourse); // should be a seperate private method - const [existingLessons] = await this.lessonRepo.findAllByCourseIds([originalLesson.course.id]); + const [existingLessons] = await this.lessonService.findByCourseIds([originalLesson.course.id]); const existingNames = existingLessons.map((l) => l.name); const copyName = this.copyHelperService.deriveCopyName(originalLesson.name, existingNames); diff --git a/apps/server/src/modules/learnroom/uc/rooms.uc.spec.ts b/apps/server/src/modules/learnroom/uc/rooms.uc.spec.ts index 7c6b82aad84..69faa5c8fd7 100644 --- a/apps/server/src/modules/learnroom/uc/rooms.uc.spec.ts +++ b/apps/server/src/modules/learnroom/uc/rooms.uc.spec.ts @@ -1,7 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { BoardRepo, CourseRepo, LessonRepo, TaskRepo, UserRepo } from '@shared/repo'; +import { BoardRepo, CourseRepo, TaskRepo, UserRepo } from '@shared/repo'; import { boardFactory, courseFactory, lessonFactory, setupEntities, taskFactory, userFactory } from '@shared/testing'; import { RoomsService } from '../service/rooms.service'; import { RoomBoardDTOFactory } from './room-board-dto.factory'; @@ -11,7 +11,6 @@ import { RoomsUc } from './rooms.uc'; describe('rooms usecase', () => { let uc: RoomsUc; let courseRepo: DeepMocked; - let lessonRepo: DeepMocked; let taskRepo: DeepMocked; let userRepo: DeepMocked; let boardRepo: DeepMocked; @@ -33,10 +32,6 @@ describe('rooms usecase', () => { provide: CourseRepo, useValue: createMock(), }, - { - provide: LessonRepo, - useValue: createMock(), - }, { provide: TaskRepo, useValue: createMock(), @@ -66,7 +61,6 @@ describe('rooms usecase', () => { uc = module.get(RoomsUc); courseRepo = module.get(CourseRepo); - lessonRepo = module.get(LessonRepo); taskRepo = module.get(TaskRepo); userRepo = module.get(UserRepo); boardRepo = module.get(BoardRepo); @@ -97,7 +91,6 @@ describe('rooms usecase', () => { const roomSpy = courseRepo.findOne.mockResolvedValue(room); const boardSpy = boardRepo.findByCourseId.mockResolvedValue(board); const tasksSpy = taskRepo.findBySingleParent.mockResolvedValue([tasks, 3]); - const lessonsSpy = lessonRepo.findAllByCourseIds.mockResolvedValue([lessons, 3]); const syncBoardElementReferencesSpy = jest.spyOn(board, 'syncBoardElementReferences'); const mapperSpy = factory.createDTO.mockReturnValue(roomBoardDTO); const saveSpy = boardRepo.save.mockResolvedValue(); @@ -114,7 +107,6 @@ describe('rooms usecase', () => { roomSpy, boardSpy, tasksSpy, - lessonsSpy, syncBoardElementReferencesSpy, mapperSpy, saveSpy, diff --git a/apps/server/src/modules/lesson/index.ts b/apps/server/src/modules/lesson/index.ts index 61e512d84b7..b552bf9c988 100644 --- a/apps/server/src/modules/lesson/index.ts +++ b/apps/server/src/modules/lesson/index.ts @@ -1,3 +1,5 @@ export * from './lesson.module'; +export * from './service/lesson-copy.service'; +export * from './service/lesson.service'; export * from './types/lesson-copy-parent.params'; export * from './types/lesson-copy.params'; diff --git a/apps/server/src/modules/lesson/lesson.module.ts b/apps/server/src/modules/lesson/lesson.module.ts index dde1eb157ec..3a009550010 100644 --- a/apps/server/src/modules/lesson/lesson.module.ts +++ b/apps/server/src/modules/lesson/lesson.module.ts @@ -1,10 +1,10 @@ -import { Module } from '@nestjs/common'; import { FeathersServiceProvider } from '@infra/feathers'; -import { LessonRepo } from '@shared/repo'; -import { LoggerModule } from '@src/core/logger'; import { CopyHelperModule } from '@modules/copy-helper'; import { FilesStorageClientModule } from '@modules/files-storage-client'; import { TaskModule } from '@modules/task'; +import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; +import { LessonRepo } from './repository'; import { EtherpadService, LessonCopyService, LessonService, NexboardService } from './service'; @Module({ diff --git a/apps/server/src/shared/repo/lesson/index.ts b/apps/server/src/modules/lesson/repository/index.ts similarity index 100% rename from apps/server/src/shared/repo/lesson/index.ts rename to apps/server/src/modules/lesson/repository/index.ts diff --git a/apps/server/src/shared/repo/lesson/lesson-scope.ts b/apps/server/src/modules/lesson/repository/lesson-scope.ts similarity index 90% rename from apps/server/src/shared/repo/lesson/lesson-scope.ts rename to apps/server/src/modules/lesson/repository/lesson-scope.ts index ef99b7624c1..81529b03f88 100644 --- a/apps/server/src/shared/repo/lesson/lesson-scope.ts +++ b/apps/server/src/modules/lesson/repository/lesson-scope.ts @@ -1,5 +1,5 @@ import { EntityId, LessonEntity } from '@shared/domain'; -import { Scope } from '../scope'; +import { Scope } from '@shared/repo'; export class LessonScope extends Scope { byCourseIds(courseIds: EntityId[]): LessonScope { diff --git a/apps/server/src/shared/repo/lesson/lesson.repo.integration.spec.ts b/apps/server/src/modules/lesson/repository/lesson.repo.integration.spec.ts similarity index 100% rename from apps/server/src/shared/repo/lesson/lesson.repo.integration.spec.ts rename to apps/server/src/modules/lesson/repository/lesson.repo.integration.spec.ts diff --git a/apps/server/src/shared/repo/lesson/lesson.repo.ts b/apps/server/src/modules/lesson/repository/lesson.repo.ts similarity index 97% rename from apps/server/src/shared/repo/lesson/lesson.repo.ts rename to apps/server/src/modules/lesson/repository/lesson.repo.ts index 26f66e9587d..c2fc2f0269e 100644 --- a/apps/server/src/shared/repo/lesson/lesson.repo.ts +++ b/apps/server/src/modules/lesson/repository/lesson.repo.ts @@ -1,7 +1,7 @@ +import { EntityDictionary } from '@mikro-orm/core'; import { Injectable } from '@nestjs/common'; import { Counted, EntityId, LessonEntity, SortOrder } from '@shared/domain'; -import { EntityDictionary } from '@mikro-orm/core'; -import { BaseRepo } from '../base.repo'; +import { BaseRepo } from '@shared/repo'; import { LessonScope } from './lesson-scope'; @Injectable() diff --git a/apps/server/src/modules/lesson/service/lesson-copy.service.spec.ts b/apps/server/src/modules/lesson/service/lesson-copy.service.spec.ts index 34392c91c0c..5e7c1aa3e59 100644 --- a/apps/server/src/modules/lesson/service/lesson-copy.service.spec.ts +++ b/apps/server/src/modules/lesson/service/lesson-copy.service.spec.ts @@ -1,5 +1,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons'; +import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; +import { CopyFilesService } from '@modules/files-storage-client'; +import { TaskCopyService } from '@modules/task'; import { Test, TestingModule } from '@nestjs/testing'; import { BaseEntity, @@ -15,7 +18,6 @@ import { Material, } from '@shared/domain'; import { AuthorizableObject } from '@shared/domain/domain-object'; -import { LessonRepo } from '@shared/repo'; import { courseFactory, lessonFactory, @@ -24,9 +26,7 @@ import { taskFactory, userFactory, } from '@shared/testing'; -import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; -import { CopyFilesService } from '@modules/files-storage-client'; -import { TaskCopyService } from '@modules/task/service'; +import { LessonRepo } from '../repository'; import { EtherpadService } from './etherpad.service'; import { LessonCopyService } from './lesson-copy.service'; import { NexboardService } from './nexboard.service'; diff --git a/apps/server/src/modules/lesson/service/lesson-copy.service.ts b/apps/server/src/modules/lesson/service/lesson-copy.service.ts index b6d7e7849c7..0ea5110b424 100644 --- a/apps/server/src/modules/lesson/service/lesson-copy.service.ts +++ b/apps/server/src/modules/lesson/service/lesson-copy.service.ts @@ -1,4 +1,7 @@ import { Configuration } from '@hpi-schul-cloud/commons'; +import { CopyDictionary, CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; +import { CopyFilesService, FileUrlReplacement } from '@modules/files-storage-client'; +import { TaskCopyService } from '@modules/task'; import { Injectable } from '@nestjs/common'; import { ComponentType, @@ -11,12 +14,8 @@ import { LessonEntity, Material, } from '@shared/domain'; -import { LessonRepo } from '@shared/repo'; -import { CopyDictionary, CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; -import { CopyFilesService } from '@modules/files-storage-client'; -import { FileUrlReplacement } from '@modules/files-storage-client/service/copy-files.service'; -import { TaskCopyService } from '@modules/task/service/task-copy.service'; import { randomBytes } from 'crypto'; +import { LessonRepo } from '../repository'; import { LessonCopyParams } from '../types'; import { EtherpadService } from './etherpad.service'; import { NexboardService } from './nexboard.service'; diff --git a/apps/server/src/modules/lesson/service/lesson.service.spec.ts b/apps/server/src/modules/lesson/service/lesson.service.spec.ts index a94ecfe9c8b..b82875d7c1f 100644 --- a/apps/server/src/modules/lesson/service/lesson.service.spec.ts +++ b/apps/server/src/modules/lesson/service/lesson.service.spec.ts @@ -1,10 +1,10 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Test, TestingModule } from '@nestjs/testing'; -import { LessonRepo } from '@shared/repo'; -import { lessonFactory, setupEntities } from '@shared/testing'; -import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { ObjectId } from '@mikro-orm/mongodb'; +import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; +import { Test, TestingModule } from '@nestjs/testing'; import { ComponentType, IComponentProperties } from '@shared/domain'; +import { lessonFactory, setupEntities } from '@shared/testing'; +import { LessonRepo } from '../repository'; import { LessonService } from './lesson.service'; describe('LessonService', () => { @@ -71,9 +71,19 @@ describe('LessonService', () => { const courseIds = ['course-1', 'course-2']; lessonRepo.findAllByCourseIds.mockResolvedValueOnce([[], 0]); - await expect(lessonService.findByCourseIds(courseIds)).resolves.not.toThrow(); - expect(lessonRepo.findAllByCourseIds).toBeCalledTimes(1); - expect(lessonRepo.findAllByCourseIds).toBeCalledWith(courseIds); + await lessonService.findByCourseIds(courseIds); + + expect(lessonRepo.findAllByCourseIds).toBeCalledWith(courseIds, undefined); + }); + + it('should pass filters', async () => { + const courseIds = ['course-1', 'course-2']; + lessonRepo.findAllByCourseIds.mockResolvedValueOnce([[], 0]); + const filters = { hidden: false }; + + await lessonService.findByCourseIds(courseIds, filters); + + expect(lessonRepo.findAllByCourseIds).toBeCalledWith(courseIds, filters); }); }); diff --git a/apps/server/src/modules/lesson/service/lesson.service.ts b/apps/server/src/modules/lesson/service/lesson.service.ts index 2dee6f05563..c98e633469e 100644 --- a/apps/server/src/modules/lesson/service/lesson.service.ts +++ b/apps/server/src/modules/lesson/service/lesson.service.ts @@ -1,10 +1,11 @@ +import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { Injectable } from '@nestjs/common'; import { Counted, EntityId, IComponentProperties, LessonEntity } from '@shared/domain'; -import { LessonRepo } from '@shared/repo'; -import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; +import { AuthorizationLoaderService } from '@src/modules/authorization'; +import { LessonRepo } from '../repository'; @Injectable() -export class LessonService { +export class LessonService implements AuthorizationLoaderService { constructor( private readonly lessonRepo: LessonRepo, private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService @@ -20,8 +21,8 @@ export class LessonService { return this.lessonRepo.findById(lessonId); } - async findByCourseIds(courseIds: EntityId[]): Promise> { - return this.lessonRepo.findAllByCourseIds(courseIds); + async findByCourseIds(courseIds: EntityId[], filters?: { hidden?: boolean }): Promise> { + return this.lessonRepo.findAllByCourseIds(courseIds, filters); } async findAllLessonsByUserId(userId: EntityId): Promise { diff --git a/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts b/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts index 72d2a824327..9ad71462008 100644 --- a/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts +++ b/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts @@ -3,8 +3,17 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { BadRequestException, InternalServerErrorException, NotImplementedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain'; -import { LessonRepo } from '@shared/repo'; +import { + Action, + AuthorizableReferenceType, + AuthorizationReferenceService, + AuthorizationService, +} from '@modules/authorization'; +import { CopyElementType, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; +import { CourseCopyService, CourseService } from '@modules/learnroom'; +import { LessonCopyService } from '@modules/lesson'; +import { TaskCopyService } from '@modules/task'; import { courseFactory, lessonFactory, @@ -15,13 +24,6 @@ import { userFactory, } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { Action, AuthorizationService } from '@modules/authorization'; -import { AuthorizableReferenceType, AuthorizationReferenceService } from '@modules/authorization/domain'; -import { CopyElementType, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; -import { CourseCopyService } from '@modules/learnroom'; -import { CourseService } from '@modules/learnroom/service'; -import { LessonCopyService } from '@modules/lesson/service'; -import { TaskCopyService } from '@modules/task/service'; import { ShareTokenContextType, ShareTokenParentType, ShareTokenPayload } from '../domainobject/share-token.do'; import { ShareTokenService } from '../service'; import { ShareTokenUC } from './share-token.uc'; @@ -36,7 +38,6 @@ describe('ShareTokenUC', () => { let authorization: DeepMocked; let authorizationReferenceService: DeepMocked; let courseService: DeepMocked; - let lessonRepo: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -62,10 +63,6 @@ describe('ShareTokenUC', () => { provide: LessonCopyService, useValue: createMock(), }, - { - provide: LessonRepo, - useValue: createMock(), - }, { provide: CourseService, useValue: createMock(), @@ -89,7 +86,6 @@ describe('ShareTokenUC', () => { authorization = module.get(AuthorizationService); authorizationReferenceService = module.get(AuthorizationReferenceService); courseService = module.get(CourseService); - lessonRepo = module.get(LessonRepo); await setupEntities(); }); @@ -729,7 +725,6 @@ describe('ShareTokenUC', () => { const course = courseFactory.buildWithId(); courseService.findById.mockResolvedValue(course); const lesson = lessonFactory.buildWithId({ course }); - lessonRepo.findById.mockResolvedValue(lesson); const status: CopyStatus = { type: CopyElementType.LESSON, diff --git a/apps/server/src/modules/task/index.ts b/apps/server/src/modules/task/index.ts index 62ab8a69e94..8734b0eb623 100644 --- a/apps/server/src/modules/task/index.ts +++ b/apps/server/src/modules/task/index.ts @@ -1 +1,4 @@ +export * from './service/submission.service'; +export * from './service/task-copy.service'; +export * from './service/task.service'; export * from './task.module'; diff --git a/apps/server/src/modules/task/task-api.module.ts b/apps/server/src/modules/task/task-api.module.ts index cdb998eab4a..6085e426f6c 100644 --- a/apps/server/src/modules/task/task-api.module.ts +++ b/apps/server/src/modules/task/task-api.module.ts @@ -1,14 +1,15 @@ -import { Module } from '@nestjs/common'; -import { CourseRepo, LessonRepo, TaskRepo } from '@shared/repo'; import { AuthorizationModule } from '@modules/authorization'; import { CopyHelperModule } from '@modules/copy-helper/copy-helper.module'; +import { Module } from '@nestjs/common'; +import { CourseRepo, TaskRepo } from '@shared/repo'; +import { LessonModule } from '@modules/lesson'; import { SubmissionController, TaskController } from './controller'; import { TaskModule } from './task.module'; import { SubmissionUc, TaskCopyUC, TaskUC } from './uc'; @Module({ - imports: [AuthorizationModule, CopyHelperModule, TaskModule], + imports: [AuthorizationModule, CopyHelperModule, TaskModule, LessonModule], controllers: [TaskController, SubmissionController], - providers: [TaskUC, TaskRepo, LessonRepo, CourseRepo, TaskCopyUC, SubmissionUc], + providers: [TaskUC, TaskRepo, CourseRepo, TaskCopyUC, SubmissionUc], }) export class TaskApiModule {} diff --git a/apps/server/src/modules/task/task.module.ts b/apps/server/src/modules/task/task.module.ts index 45a0fdb720a..87ecf144798 100644 --- a/apps/server/src/modules/task/task.module.ts +++ b/apps/server/src/modules/task/task.module.ts @@ -1,12 +1,12 @@ import { CopyHelperModule } from '@modules/copy-helper'; import { FilesStorageClientModule } from '@modules/files-storage-client'; import { Module } from '@nestjs/common'; -import { CourseRepo, LessonRepo, SubmissionRepo, TaskRepo } from '@shared/repo'; +import { CourseRepo, SubmissionRepo, TaskRepo } from '@shared/repo'; import { SubmissionService, TaskCopyService, TaskService } from './service'; @Module({ imports: [FilesStorageClientModule, CopyHelperModule], - providers: [TaskService, TaskCopyService, SubmissionService, TaskRepo, LessonRepo, CourseRepo, SubmissionRepo], + providers: [TaskService, TaskCopyService, SubmissionService, TaskRepo, CourseRepo, SubmissionRepo], exports: [TaskService, TaskCopyService, SubmissionService], }) export class TaskModule {} diff --git a/apps/server/src/modules/task/uc/task-copy.uc.spec.ts b/apps/server/src/modules/task/uc/task-copy.uc.spec.ts index dc381cda22e..182294cfd91 100644 --- a/apps/server/src/modules/task/uc/task-copy.uc.spec.ts +++ b/apps/server/src/modules/task/uc/task-copy.uc.spec.ts @@ -1,23 +1,24 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons'; import { ObjectId } from '@mikro-orm/mongodb'; -import { ForbiddenException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { CourseRepo, LessonRepo, TaskRepo, UserRepo } from '@shared/repo'; -import { courseFactory, lessonFactory, setupEntities, taskFactory, userFactory } from '@shared/testing'; import { Action, AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { CopyElementType, CopyHelperService, CopyStatusEnum } from '@modules/copy-helper'; import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; +import { LessonService } from '@modules/lesson'; +import { ForbiddenException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { CourseRepo, TaskRepo, UserRepo } from '@shared/repo'; +import { courseFactory, lessonFactory, setupEntities, taskFactory, userFactory } from '@shared/testing'; import { TaskCopyService } from '../service'; -import { TaskCopyUC } from './task-copy.uc'; import { TaskCopyParentParams } from '../types'; +import { TaskCopyUC } from './task-copy.uc'; describe('task copy uc', () => { let uc: TaskCopyUC; let userRepo: DeepMocked; let taskRepo: DeepMocked; let courseRepo: DeepMocked; - let lessonRepo: DeepMocked; + let lessonService: DeepMocked; let authorisation: DeepMocked; let taskCopyService: DeepMocked; let copyHelperService: DeepMocked; @@ -42,8 +43,8 @@ describe('task copy uc', () => { useValue: createMock(), }, { - provide: LessonRepo, - useValue: createMock(), + provide: LessonService, + useValue: createMock(), }, { provide: AuthorizationService, @@ -69,7 +70,7 @@ describe('task copy uc', () => { taskRepo = module.get(TaskRepo); authorisation = module.get(AuthorizationService); courseRepo = module.get(CourseRepo); - lessonRepo = module.get(LessonRepo); + lessonService = module.get(LessonService); taskCopyService = module.get(TaskCopyService); copyHelperService = module.get(CopyHelperService); }); @@ -104,7 +105,7 @@ describe('task copy uc', () => { authorisation.getUserWithPermissions.mockResolvedValueOnce(user); taskRepo.findById.mockResolvedValueOnce(task); - lessonRepo.findById.mockResolvedValueOnce(lesson); + lessonService.findById.mockResolvedValueOnce(lesson); taskRepo.findBySingleParent.mockResolvedValueOnce([allTasks, allTasks.length]); courseRepo.findById.mockResolvedValueOnce(course); authorisation.hasPermission.mockReturnValueOnce(true).mockReturnValueOnce(true); @@ -187,7 +188,7 @@ describe('task copy uc', () => { await uc.copyTask(user.id, task.id, { lessonId: lesson.id, userId }); - expect(lessonRepo.findById).toBeCalledWith(lesson.id); + expect(lessonService.findById).toBeCalledWith(lesson.id); }); it('should pass without destination lesson', async () => { @@ -195,7 +196,7 @@ describe('task copy uc', () => { await uc.copyTask(user.id, task.id, { userId }); - expect(lessonRepo.findById).not.toHaveBeenCalled(); + expect(lessonService.findById).not.toHaveBeenCalled(); }); }); @@ -365,7 +366,7 @@ describe('task copy uc', () => { userRepo.findById.mockResolvedValueOnce(user); taskRepo.findById.mockResolvedValueOnce(task); courseRepo.findById.mockResolvedValueOnce(course); - lessonRepo.findById.mockResolvedValueOnce(lesson); + lessonService.findById.mockResolvedValueOnce(lesson); // first canReadTask > second canWriteLesson authorisation.hasPermission.mockReturnValueOnce(true).mockReturnValueOnce(false); diff --git a/apps/server/src/modules/task/uc/task-copy.uc.ts b/apps/server/src/modules/task/uc/task-copy.uc.ts index 69fd99e224f..58316aa1972 100644 --- a/apps/server/src/modules/task/uc/task-copy.uc.ts +++ b/apps/server/src/modules/task/uc/task-copy.uc.ts @@ -1,9 +1,10 @@ import { Configuration } from '@hpi-schul-cloud/commons'; -import { ForbiddenException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; -import { Course, EntityId, Task, LessonEntity, User } from '@shared/domain'; -import { CourseRepo, LessonRepo, TaskRepo } from '@shared/repo'; import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { CopyHelperService, CopyStatus } from '@modules/copy-helper'; +import { ForbiddenException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { Course, EntityId, LessonEntity, Task, User } from '@shared/domain'; +import { CourseRepo, TaskRepo } from '@shared/repo'; +import { LessonService } from '@modules/lesson'; import { TaskCopyService } from '../service'; import { TaskCopyParentParams } from '../types'; @@ -11,7 +12,7 @@ import { TaskCopyParentParams } from '../types'; export class TaskCopyUC { constructor( private readonly courseRepo: CourseRepo, - private readonly lessonRepo: LessonRepo, + private readonly lessonService: LessonService, private readonly authorisation: AuthorizationService, private readonly taskCopyService: TaskCopyService, private readonly taskRepo: TaskRepo, @@ -104,7 +105,7 @@ export class TaskCopyUC { return undefined; } - const destinationLesson = await this.lessonRepo.findById(lessonId); + const destinationLesson = await this.lessonService.findById(lessonId); return destinationLesson; } diff --git a/apps/server/src/modules/task/uc/task.uc.spec.ts b/apps/server/src/modules/task/uc/task.uc.spec.ts index 90bb29db444..e836f907f26 100644 --- a/apps/server/src/modules/task/uc/task.uc.spec.ts +++ b/apps/server/src/modules/task/uc/task.uc.spec.ts @@ -1,9 +1,10 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Action, AuthorizationService } from '@modules/authorization'; import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { PaginationParams } from '@shared/controller'; import { ITaskStatus, Permission, SortOrder } from '@shared/domain'; -import { CourseRepo, LessonRepo, TaskRepo } from '@shared/repo'; +import { CourseRepo, TaskRepo } from '@shared/repo'; import { courseFactory, lessonFactory, @@ -13,7 +14,7 @@ import { taskFactory, userFactory, } from '@shared/testing'; -import { Action, AuthorizationService } from '@modules/authorization'; +import { LessonService } from '@modules/lesson'; import { TaskService } from '../service'; import { TaskUC } from './task.uc'; @@ -22,7 +23,7 @@ describe('TaskUC', () => { let service: TaskUC; let taskRepo: DeepMocked; let courseRepo: DeepMocked; - let lessonRepo: DeepMocked; + let lessonService: DeepMocked; let authorizationService: DeepMocked; let taskService: DeepMocked; @@ -48,8 +49,8 @@ describe('TaskUC', () => { useValue: createMock(), }, { - provide: LessonRepo, - useValue: createMock(), + provide: LessonService, + useValue: createMock(), }, { provide: AuthorizationService, @@ -69,7 +70,7 @@ describe('TaskUC', () => { service = module.get(TaskUC); taskRepo = module.get(TaskRepo); courseRepo = module.get(CourseRepo); - lessonRepo = module.get(LessonRepo); + lessonService = module.get(LessonService); authorizationService = module.get(AuthorizationService); taskService = module.get(TaskService); }); @@ -90,8 +91,8 @@ describe('TaskUC', () => { const finishedTask = taskFactory.finished(user).build(); authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); courseRepo.findAllByUserId.mockResolvedValueOnce([[], 0]); - lessonRepo.findAllByCourseIds.mockResolvedValueOnce([[], 0]); - lessonRepo.findAllByCourseIds.mockResolvedValueOnce([[], 0]); + lessonService.findByCourseIds.mockResolvedValueOnce([[], 0]); + lessonService.findByCourseIds.mockResolvedValueOnce([[], 0]); authorizationService.hasPermission.mockReturnValueOnce(false); taskRepo.findAllFinishedByParentIds.mockResolvedValueOnce([[finishedTask], 1]); @@ -204,8 +205,8 @@ describe('TaskUC', () => { authorizationService.getUserWithPermissions.mockResolvedValue(user); courseRepo.findAllByUserId.mockResolvedValue([[], 0]); - lessonRepo.findAllByCourseIds.mockResolvedValueOnce([[lesson], 1]); - lessonRepo.findAllByCourseIds.mockResolvedValueOnce([[], 0]); + lessonService.findByCourseIds.mockResolvedValueOnce([[lesson], 1]); + lessonService.findByCourseIds.mockResolvedValueOnce([[], 0]); authorizationService.hasPermission.mockReturnValueOnce(false); taskRepo.findAllFinishedByParentIds.mockResolvedValue([[task], 1]); @@ -240,8 +241,8 @@ describe('TaskUC', () => { authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); courseRepo.findAllByUserId.mockResolvedValueOnce([[course], 1]); - lessonRepo.findAllByCourseIds.mockResolvedValueOnce([[], 0]); - lessonRepo.findAllByCourseIds.mockResolvedValueOnce([[], 0]); + lessonService.findByCourseIds.mockResolvedValueOnce([[], 0]); + lessonService.findByCourseIds.mockResolvedValueOnce([[], 0]); authorizationService.hasPermission.mockReturnValueOnce(false); taskRepo.findAllFinishedByParentIds.mockResolvedValueOnce([[task], 1]); @@ -276,8 +277,8 @@ describe('TaskUC', () => { authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); courseRepo.findAllByUserId.mockResolvedValueOnce([[], 0]); - lessonRepo.findAllByCourseIds.mockResolvedValueOnce([[], 0]); - lessonRepo.findAllByCourseIds.mockResolvedValueOnce([[], 0]); + lessonService.findByCourseIds.mockResolvedValueOnce([[], 0]); + lessonService.findByCourseIds.mockResolvedValueOnce([[], 0]); authorizationService.hasPermission.mockReturnValueOnce(true); taskRepo.findAllFinishedByParentIds.mockResolvedValueOnce([[task], 1]); @@ -303,8 +304,8 @@ describe('TaskUC', () => { authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); courseRepo.findAllByUserId.mockResolvedValueOnce([[course], 1]); - lessonRepo.findAllByCourseIds.mockResolvedValueOnce([[], 0]); - lessonRepo.findAllByCourseIds.mockResolvedValueOnce([[], 0]); + lessonService.findByCourseIds.mockResolvedValueOnce([[], 0]); + lessonService.findByCourseIds.mockResolvedValueOnce([[], 0]); authorizationService.hasPermission.mockReturnValueOnce(true); authorizationService.hasPermission.mockReturnValueOnce(true); taskRepo.findAllFinishedByParentIds.mockResolvedValueOnce([[task], 1]); @@ -389,8 +390,8 @@ describe('TaskUC', () => { authorizationService.hasAllPermissions.mockReturnValueOnce(true); courseRepo.findAllByUserId.mockResolvedValueOnce([[course], 1]); authorizationService.hasPermission.mockReturnValueOnce(false); - lessonRepo.findAllByCourseIds.mockResolvedValueOnce([[], 0]); - lessonRepo.findAllByCourseIds.mockResolvedValueOnce([[lesson], 1]); + lessonService.findByCourseIds.mockResolvedValueOnce([[], 0]); + lessonService.findByCourseIds.mockResolvedValueOnce([[lesson], 1]); taskRepo.findAllByParentIds.mockResolvedValueOnce([[task1, task2, task3], 3]); return { user, course, lesson, task1, paginationParams }; @@ -503,8 +504,8 @@ describe('TaskUC', () => { authorizationService.hasAllPermissions.mockReturnValueOnce(true); courseRepo.findAllForTeacherOrSubstituteTeacher.mockResolvedValueOnce([[course], 1]); authorizationService.hasPermission.mockReturnValueOnce(true); - lessonRepo.findAllByCourseIds.mockResolvedValueOnce([[lesson], 1]); - lessonRepo.findAllByCourseIds.mockResolvedValueOnce([[], 0]); + lessonService.findByCourseIds.mockResolvedValueOnce([[lesson], 1]); + lessonService.findByCourseIds.mockResolvedValueOnce([[], 0]); taskRepo.findAllByParentIds.mockResolvedValueOnce([[task], 1]); return { user, paginationParams }; @@ -538,8 +539,8 @@ describe('TaskUC', () => { authorizationService.hasAllPermissions.mockReturnValueOnce(true); courseRepo.findAllForTeacherOrSubstituteTeacher.mockResolvedValueOnce([[course], 1]); authorizationService.hasPermission.mockReturnValueOnce(true); - lessonRepo.findAllByCourseIds.mockResolvedValueOnce([[lesson], 1]); - lessonRepo.findAllByCourseIds.mockResolvedValueOnce([[], 0]); + lessonService.findByCourseIds.mockResolvedValueOnce([[lesson], 1]); + lessonService.findByCourseIds.mockResolvedValueOnce([[], 0]); taskRepo.findAllByParentIds.mockResolvedValueOnce([[task1, task2, task3], 3]); return { user, course, lesson, task1, paginationParams }; diff --git a/apps/server/src/modules/task/uc/task.uc.ts b/apps/server/src/modules/task/uc/task.uc.ts index a6e40dd3b6d..69ca7b32c8f 100644 --- a/apps/server/src/modules/task/uc/task.uc.ts +++ b/apps/server/src/modules/task/uc/task.uc.ts @@ -1,3 +1,4 @@ +import { Action, AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Counted, @@ -11,8 +12,8 @@ import { TaskWithStatusVo, User, } from '@shared/domain'; -import { CourseRepo, LessonRepo, TaskRepo } from '@shared/repo'; -import { Action, AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { CourseRepo, TaskRepo } from '@shared/repo'; +import { LessonService } from '@modules/lesson'; import { TaskService } from '../service'; @Injectable() @@ -21,7 +22,7 @@ export class TaskUC { private readonly taskRepo: TaskRepo, private readonly authorizationService: AuthorizationService, private readonly courseRepo: CourseRepo, - private readonly lessonRepo: LessonRepo, + private readonly lessonService: LessonService, private readonly taskService: TaskService ) {} @@ -206,8 +207,8 @@ export class TaskUC { // idea as combined query: // [{courseIds: onlyWriteCoursesIds}, { courseIds: onlyReadCourses, filter: { hidden: false }}] const [[writeLessons], [readLessons]] = await Promise.all([ - this.lessonRepo.findAllByCourseIds(writeCourseIds), - this.lessonRepo.findAllByCourseIds(readCourseIds, { hidden: false }), + this.lessonService.findByCourseIds(writeCourseIds), + this.lessonService.findByCourseIds(readCourseIds, { hidden: false }), ]); const permittedLessons = [...writeLessons, ...readLessons]; diff --git a/apps/server/src/modules/tool/index.ts b/apps/server/src/modules/tool/index.ts index 5af54483f5b..a7a6c23ddab 100644 --- a/apps/server/src/modules/tool/index.ts +++ b/apps/server/src/modules/tool/index.ts @@ -1,6 +1,7 @@ -export * from './school-external-tool/entity/school-external-tool.entity'; export * from './common/entity/custom-parameter-entry.entity'; +export * from './common/interface'; export * from './context-external-tool/entity'; +export * from './context-external-tool/service/context-external-tool-authorizable.service'; export * from './external-tool'; +export * from './school-external-tool/entity/school-external-tool.entity'; export * from './tool.module'; -export * from './common/interface'; diff --git a/apps/server/src/shared/repo/index.ts b/apps/server/src/shared/repo/index.ts index 715a78ee420..ce9304ec7ef 100644 --- a/apps/server/src/shared/repo/index.ts +++ b/apps/server/src/shared/repo/index.ts @@ -12,7 +12,6 @@ export * from './coursegroup'; export * from './dashboard'; export * from './federalstate'; export * from './importuser'; -export * from './lesson'; export * from './ltitool'; export * from './materials'; export * from './mongo.patterns'; From 84c267debe5b1f8e00cbdf69098ea8c30fadd545 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport <106819770+hoeppner-dataport@users.noreply.github.com> Date: Mon, 20 Nov 2023 13:43:45 +0100 Subject: [PATCH 2/2] BC-5642 - internal links in LinkElements on boards (#4549) The collection of meta tags for internal pages of the project can not be done via scraping. Therefore a set of rules for types of urls and implementations to gather the needed data was implemented. --- .../update-element-content.body.params.ts | 4 +- .../meta-tag-extractor-get-data.api.spec.ts | 8 +- .../dto/meta-tag-extractor.response.spec.ts | 16 ++- .../dto/meta-tag-extractor.response.ts | 18 ++- .../meta-tag-extractor.controller.ts | 7 +- .../controller/post-link-url.body.params.ts | 4 +- .../interface/url-handler.ts | 6 + .../meta-tag-extractor-api.module.ts | 2 +- .../meta-tag-extractor.module.ts | 19 ++- .../meta-tag-extractor.service.spec.ts | 79 ++++++---- .../service/meta-tag-extractor.service.ts | 31 ++-- .../meta-tag-internal-url.service.spec.ts | 136 ++++++++++++++++++ .../service/meta-tag-internal-url.service.ts | 51 +++++++ .../url-handler/abstract-url-handler.spec.ts | 59 ++++++++ .../url-handler/abstract-url-handler.ts | 35 +++++ .../url-handler/board-url-handler.spec.ts | 70 +++++++++ .../service/url-handler/board-url-handler.ts | 37 +++++ .../url-handler/course-url-handler.spec.ts | 62 ++++++++ .../service/url-handler/course-url-handler.ts | 29 ++++ .../service/url-handler/index.ts | 4 + .../url-handler/lesson-url-handler.spec.ts | 62 ++++++++ .../service/url-handler/lesson-url-handler.ts | 29 ++++ .../url-handler/task-url-handler.spec.ts | 62 ++++++++ .../service/url-handler/task-url-handler.ts | 29 ++++ .../modules/meta-tag-extractor/types/index.ts | 1 + .../types/meta-data.type.ts | 13 ++ .../uc/meta-tag-extractor.uc.spec.ts | 12 +- .../uc/meta-tag-extractor.uc.ts | 9 +- 28 files changed, 825 insertions(+), 69 deletions(-) create mode 100644 apps/server/src/modules/meta-tag-extractor/interface/url-handler.ts create mode 100644 apps/server/src/modules/meta-tag-extractor/service/meta-tag-internal-url.service.spec.ts create mode 100644 apps/server/src/modules/meta-tag-extractor/service/meta-tag-internal-url.service.ts create mode 100644 apps/server/src/modules/meta-tag-extractor/service/url-handler/abstract-url-handler.spec.ts create mode 100644 apps/server/src/modules/meta-tag-extractor/service/url-handler/abstract-url-handler.ts create mode 100644 apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.spec.ts create mode 100644 apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.ts create mode 100644 apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.spec.ts create mode 100644 apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.ts create mode 100644 apps/server/src/modules/meta-tag-extractor/service/url-handler/index.ts create mode 100644 apps/server/src/modules/meta-tag-extractor/service/url-handler/lesson-url-handler.spec.ts create mode 100644 apps/server/src/modules/meta-tag-extractor/service/url-handler/lesson-url-handler.ts create mode 100644 apps/server/src/modules/meta-tag-extractor/service/url-handler/task-url-handler.spec.ts create mode 100644 apps/server/src/modules/meta-tag-extractor/service/url-handler/task-url-handler.ts create mode 100644 apps/server/src/modules/meta-tag-extractor/types/index.ts create mode 100644 apps/server/src/modules/meta-tag-extractor/types/meta-data.type.ts diff --git a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts index 23ce88b904c..7d0314208c6 100644 --- a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts +++ b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts @@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger import { ContentElementType } from '@shared/domain'; import { InputFormat } from '@shared/domain/types'; import { Type } from 'class-transformer'; -import { IsDate, IsEnum, IsMongoId, IsOptional, IsString, IsUrl, ValidateNested } from 'class-validator'; +import { IsDate, IsEnum, IsMongoId, IsOptional, IsString, ValidateNested } from 'class-validator'; export abstract class ElementContentBody { @IsEnum(ContentElementType) @@ -34,7 +34,7 @@ export class FileElementContentBody extends ElementContentBody { } export class LinkContentBody { - @IsUrl() + @IsString() @ApiProperty({}) url!: string; diff --git a/apps/server/src/modules/meta-tag-extractor/controller/api-test/meta-tag-extractor-get-data.api.spec.ts b/apps/server/src/modules/meta-tag-extractor/controller/api-test/meta-tag-extractor-get-data.api.spec.ts index c80d47df66d..2a6c2a93d90 100644 --- a/apps/server/src/modules/meta-tag-extractor/controller/api-test/meta-tag-extractor-get-data.api.spec.ts +++ b/apps/server/src/modules/meta-tag-extractor/controller/api-test/meta-tag-extractor-get-data.api.spec.ts @@ -5,7 +5,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; import { MetaTagExtractorService } from '../../service'; -const URL = 'https://test.de'; +const URL = 'https://best-example.de/my-article'; const mockedResponse = { url: URL, @@ -13,7 +13,7 @@ const mockedResponse = { description: 'with great description', }; -describe(`get data (api)`, () => { +describe(`get meta tags (api)`, () => { let app: INestApplication; let em: EntityManager; let testApiClient: TestApiClient; @@ -24,7 +24,7 @@ describe(`get data (api)`, () => { }) .overrideProvider(MetaTagExtractorService) .useValue({ - fetchMetaData: () => mockedResponse, + getMetaData: () => mockedResponse, }) .compile(); @@ -63,7 +63,7 @@ describe(`get data (api)`, () => { const response = await loggedInClient.post(undefined, { url: URL }); - expect(response?.body).toEqual(mockedResponse); + expect(response?.body).toEqual(expect.objectContaining(mockedResponse)); }); }); diff --git a/apps/server/src/modules/meta-tag-extractor/controller/dto/meta-tag-extractor.response.spec.ts b/apps/server/src/modules/meta-tag-extractor/controller/dto/meta-tag-extractor.response.spec.ts index 29dfbd94c72..0f527d1139d 100644 --- a/apps/server/src/modules/meta-tag-extractor/controller/dto/meta-tag-extractor.response.spec.ts +++ b/apps/server/src/modules/meta-tag-extractor/controller/dto/meta-tag-extractor.response.spec.ts @@ -8,13 +8,19 @@ describe(MetaTagExtractorResponse.name, () => { title: 'Testbild', description: 'Here we describe what this page is about.', imageUrl: 'https://www.abc.de/test.png', + type: 'unknown', + parentTitle: 'Math', + parentType: 'course', }; - const errorResponse = new MetaTagExtractorResponse(properties); - expect(errorResponse.url).toEqual(properties.url); - expect(errorResponse.title).toEqual(properties.title); - expect(errorResponse.description).toEqual(properties.description); - expect(errorResponse.imageUrl).toEqual(properties.imageUrl); + const response = new MetaTagExtractorResponse(properties); + expect(response.url).toEqual(properties.url); + expect(response.title).toEqual(properties.title); + expect(response.description).toEqual(properties.description); + expect(response.imageUrl).toEqual(properties.imageUrl); + expect(response.type).toEqual(properties.type); + expect(response.parentTitle).toEqual(properties.parentTitle); + expect(response.parentType).toEqual(properties.parentType); }); }); }); diff --git a/apps/server/src/modules/meta-tag-extractor/controller/dto/meta-tag-extractor.response.ts b/apps/server/src/modules/meta-tag-extractor/controller/dto/meta-tag-extractor.response.ts index a2f5acd8465..16863f0e16a 100644 --- a/apps/server/src/modules/meta-tag-extractor/controller/dto/meta-tag-extractor.response.ts +++ b/apps/server/src/modules/meta-tag-extractor/controller/dto/meta-tag-extractor.response.ts @@ -1,13 +1,17 @@ import { ApiProperty } from '@nestjs/swagger'; import { DecodeHtmlEntities } from '@shared/controller'; import { IsString, IsUrl } from 'class-validator'; +import { MetaDataEntityType } from '../../types'; export class MetaTagExtractorResponse { - constructor({ url, title, description, imageUrl }: MetaTagExtractorResponse) { + constructor({ url, title, description, imageUrl, type, parentTitle, parentType }: MetaTagExtractorResponse) { this.url = url; this.title = title; this.description = description; this.imageUrl = imageUrl; + this.type = type; + this.parentTitle = parentTitle; + this.parentType = parentType; } @ApiProperty() @@ -25,4 +29,16 @@ export class MetaTagExtractorResponse { @ApiProperty() @IsString() imageUrl?: string; + + @ApiProperty() + @IsString() + type: MetaDataEntityType; + + @ApiProperty() + @DecodeHtmlEntities() + parentTitle?: string; + + @ApiProperty() + @IsString() + parentType?: MetaDataEntityType; } diff --git a/apps/server/src/modules/meta-tag-extractor/controller/meta-tag-extractor.controller.ts b/apps/server/src/modules/meta-tag-extractor/controller/meta-tag-extractor.controller.ts index 8133c4c0b83..79f798c9e29 100644 --- a/apps/server/src/modules/meta-tag-extractor/controller/meta-tag-extractor.controller.ts +++ b/apps/server/src/modules/meta-tag-extractor/controller/meta-tag-extractor.controller.ts @@ -1,7 +1,6 @@ +import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; import { Body, Controller, InternalServerErrorException, Post, UnauthorizedException } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { ICurrentUser } from '@src/modules/authentication'; -import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; import { MetaTagExtractorUc } from '../uc'; import { MetaTagExtractorResponse } from './dto'; import { GetMetaTagDataBody } from './post-link-url.body.params'; @@ -17,11 +16,11 @@ export class MetaTagExtractorController { @ApiResponse({ status: 401, type: UnauthorizedException }) @ApiResponse({ status: 500, type: InternalServerErrorException }) @Post('') - async getData( + async getMetaTags( @CurrentUser() currentUser: ICurrentUser, @Body() bodyParams: GetMetaTagDataBody ): Promise { - const result = await this.metaTagExtractorUc.fetchMetaData(currentUser.userId, bodyParams.url); + const result = await this.metaTagExtractorUc.getMetaData(currentUser.userId, bodyParams.url); const imageUrl = result.image?.url; const response = new MetaTagExtractorResponse({ ...result, imageUrl }); return response; diff --git a/apps/server/src/modules/meta-tag-extractor/controller/post-link-url.body.params.ts b/apps/server/src/modules/meta-tag-extractor/controller/post-link-url.body.params.ts index 1e9cd1f7f34..ac6baeebbe8 100644 --- a/apps/server/src/modules/meta-tag-extractor/controller/post-link-url.body.params.ts +++ b/apps/server/src/modules/meta-tag-extractor/controller/post-link-url.body.params.ts @@ -1,8 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsUrl } from 'class-validator'; +import { IsString } from 'class-validator'; export class GetMetaTagDataBody { - @IsUrl() + @IsString() @ApiProperty({ required: true, nullable: false, diff --git a/apps/server/src/modules/meta-tag-extractor/interface/url-handler.ts b/apps/server/src/modules/meta-tag-extractor/interface/url-handler.ts new file mode 100644 index 00000000000..fc09d2cd40e --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/interface/url-handler.ts @@ -0,0 +1,6 @@ +import { MetaData } from '../types'; + +export interface UrlHandler { + doesUrlMatch(url: string): boolean; + getMetaData(url: string): Promise; +} diff --git a/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor-api.module.ts b/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor-api.module.ts index d9095315e87..acc5eb31776 100644 --- a/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor-api.module.ts +++ b/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor-api.module.ts @@ -1,6 +1,6 @@ +import { AuthorizationModule } from '@modules/authorization'; import { forwardRef, Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; -import { AuthorizationModule } from '@src/modules/authorization'; import { MetaTagExtractorController } from './controller'; import { MetaTagExtractorModule } from './meta-tag-extractor.module'; import { MetaTagExtractorUc } from './uc'; diff --git a/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor.module.ts b/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor.module.ts index a85e71c526f..d7a1e0faeff 100644 --- a/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor.module.ts +++ b/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor.module.ts @@ -5,20 +5,37 @@ import { ConsoleWriterModule } from '@infra/console'; import { createConfigModuleOptions } from '@src/config'; import { LoggerModule } from '@src/core/logger'; import { AuthenticationModule } from '../authentication/authentication.module'; +import { BoardModule } from '../board'; +import { LearnroomModule } from '../learnroom'; +import { LessonModule } from '../lesson'; +import { TaskModule } from '../task'; import { UserModule } from '../user'; import metaTagExtractorConfig from './meta-tag-extractor.config'; import { MetaTagExtractorService } from './service'; +import { MetaTagInternalUrlService } from './service/meta-tag-internal-url.service'; +import { BoardUrlHandler, CourseUrlHandler, LessonUrlHandler, TaskUrlHandler } from './service/url-handler'; @Module({ imports: [ AuthenticationModule, + BoardModule, ConsoleWriterModule, HttpModule, + LearnroomModule, + LessonModule, LoggerModule, + TaskModule, UserModule, ConfigModule.forRoot(createConfigModuleOptions(metaTagExtractorConfig)), ], - providers: [MetaTagExtractorService], + providers: [ + MetaTagExtractorService, + MetaTagInternalUrlService, + TaskUrlHandler, + LessonUrlHandler, + CourseUrlHandler, + BoardUrlHandler, + ], exports: [MetaTagExtractorService], }) export class MetaTagExtractorModule {} diff --git a/apps/server/src/modules/meta-tag-extractor/service/meta-tag-extractor.service.spec.ts b/apps/server/src/modules/meta-tag-extractor/service/meta-tag-extractor.service.spec.ts index af1a256d121..06fa3b09170 100644 --- a/apps/server/src/modules/meta-tag-extractor/service/meta-tag-extractor.service.spec.ts +++ b/apps/server/src/modules/meta-tag-extractor/service/meta-tag-extractor.service.spec.ts @@ -1,35 +1,52 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { Test, TestingModule } from '@nestjs/testing'; import { setupEntities } from '@shared/testing'; +import ogs from 'open-graph-scraper'; import { ImageObject } from 'open-graph-scraper/dist/lib/types'; import { MetaTagExtractorService } from './meta-tag-extractor.service'; +import { MetaTagInternalUrlService } from './meta-tag-internal-url.service'; -let ogsResponseMock = {}; -let ogsRejectMock: Error | undefined; - -jest.mock('open-graph-scraper', () => () => { - if (ogsRejectMock) { - return Promise.reject(ogsRejectMock); - } +jest.mock('open-graph-scraper', () => { + return { + __esModule: true, + default: jest.fn(), + }; +}); - return Promise.resolve({ +const mockOgsResolve = (result: Record) => { + const mockedOgs = ogs as jest.Mock; + mockedOgs.mockResolvedValueOnce({ error: false, html: '', response: {}, - result: ogsResponseMock, + result, }); -}); +}; + +const mockOgsReject = (error: Error) => { + const mockedOgs = ogs as jest.Mock; + mockedOgs.mockRejectedValueOnce(error); +}; describe(MetaTagExtractorService.name, () => { let module: TestingModule; + let metaTagInternalUrlService: DeepMocked; let service: MetaTagExtractorService; beforeAll(async () => { module = await Test.createTestingModule({ - providers: [MetaTagExtractorService], + providers: [ + MetaTagExtractorService, + { + provide: MetaTagInternalUrlService, + useValue: createMock(), + }, + ], }).compile(); + metaTagInternalUrlService = module.get(MetaTagInternalUrlService); service = module.get(MetaTagExtractorService); - await setupEntities(); }); @@ -38,8 +55,8 @@ describe(MetaTagExtractorService.name, () => { }); beforeEach(() => { - ogsResponseMock = {}; - ogsRejectMock = undefined; + Configuration.set('SC_DOMAIN', 'localhost'); + metaTagInternalUrlService.tryInternalLinkMetaTags.mockResolvedValue(undefined); }); afterEach(() => { @@ -48,26 +65,28 @@ describe(MetaTagExtractorService.name, () => { describe('create', () => { describe('when url points to webpage', () => { + it('should thrown an error if url is an empty string', async () => { + const url = ''; + + await expect(service.getMetaData(url)).rejects.toThrow(); + }); + it('should return also the original url', async () => { + const ogTitle = 'My Title'; const url = 'https://de.wikipedia.org'; + mockOgsResolve({ url, ogTitle }); - const result = await service.fetchMetaData(url); + const result = await service.getMetaData(url); expect(result).toEqual(expect.objectContaining({ url })); }); - it('should thrown an error if url is an empty string', async () => { - const url = ''; - - await expect(service.fetchMetaData(url)).rejects.toThrow(); - }); - it('should return ogTitle as title', async () => { const ogTitle = 'My Title'; const url = 'https://de.wikipedia.org'; - ogsResponseMock = { ogTitle }; + mockOgsResolve({ ogTitle }); - const result = await service.fetchMetaData(url); + const result = await service.getMetaData(url); expect(result).toEqual(expect.objectContaining({ title: ogTitle })); }); @@ -91,9 +110,9 @@ describe(MetaTagExtractorService.name, () => { }, ]; const url = 'https://de.wikipedia.org'; - ogsResponseMock = { ogImage }; + mockOgsResolve({ url, ogImage }); - const result = await service.fetchMetaData(url); + const result = await service.getMetaData(url); expect(result).toEqual(expect.objectContaining({ image: ogImage[1] })); }); @@ -102,9 +121,10 @@ describe(MetaTagExtractorService.name, () => { describe('when url points to a file', () => { it('should return filename as title', async () => { const url = 'https://de.wikipedia.org/abc.jpg'; - ogsRejectMock = new Error('no open graph data included... probably not a webpage'); - const result = await service.fetchMetaData(url); + mockOgsReject(new Error('no open graph data included... probably not a webpage')); + + const result = await service.getMetaData(url); expect(result).toEqual(expect.objectContaining({ title: 'abc.jpg' })); }); }); @@ -112,9 +132,10 @@ describe(MetaTagExtractorService.name, () => { describe('when url is invalid', () => { it('should return url as it is', async () => { const url = 'not-a-real-domain'; - ogsRejectMock = new Error('no open graph data included... probably not a webpage'); - const result = await service.fetchMetaData(url); + mockOgsReject(new Error('no open graph data included... probably not a webpage')); + + const result = await service.getMetaData(url); expect(result).toEqual(expect.objectContaining({ url, title: '', description: '' })); }); }); diff --git a/apps/server/src/modules/meta-tag-extractor/service/meta-tag-extractor.service.ts b/apps/server/src/modules/meta-tag-extractor/service/meta-tag-extractor.service.ts index 46c30c17702..d64c3a42a58 100644 --- a/apps/server/src/modules/meta-tag-extractor/service/meta-tag-extractor.service.ts +++ b/apps/server/src/modules/meta-tag-extractor/service/meta-tag-extractor.service.ts @@ -2,24 +2,29 @@ import { Injectable } from '@nestjs/common'; import ogs from 'open-graph-scraper'; import { ImageObject } from 'open-graph-scraper/dist/lib/types'; import { basename } from 'path'; - -export type MetaData = { - title: string; - description: string; - url: string; - image?: ImageObject; -}; +import type { MetaData } from '../types'; +import { MetaTagInternalUrlService } from './meta-tag-internal-url.service'; @Injectable() export class MetaTagExtractorService { - async fetchMetaData(url: string): Promise { + constructor(private readonly internalLinkMataTagService: MetaTagInternalUrlService) {} + + async getMetaData(url: string): Promise { if (url.length === 0) { throw new Error(`MetaTagExtractorService requires a valid URL. Given URL: ${url}`); } - const metaData = (await this.tryExtractMetaTags(url)) ?? this.tryFilenameAsFallback(url); + const metaData = + (await this.tryInternalLinkMetaTags(url)) ?? + (await this.tryExtractMetaTags(url)) ?? + this.tryFilenameAsFallback(url) ?? + this.getDefaultMetaData(url); + + return metaData; + } - return metaData ?? { url, title: '', description: '' }; + private async tryInternalLinkMetaTags(url: string): Promise { + return this.internalLinkMataTagService.tryInternalLinkMetaTags(url); } private async tryExtractMetaTags(url: string): Promise { @@ -35,6 +40,7 @@ export class MetaTagExtractorService { description, image, url, + type: 'external', }; } catch (error) { return undefined; @@ -49,12 +55,17 @@ export class MetaTagExtractorService { title, description: '', url, + type: 'unknown', }; } catch (error) { return undefined; } } + private getDefaultMetaData(url: string): MetaData { + return { url, title: '', description: '', type: 'unknown' }; + } + private pickImage(images: ImageObject[], minWidth = 400): ImageObject | undefined { const sortedImages = [...images]; sortedImages.sort((a, b) => (a.width && b.width ? Number(a.width) - Number(b.width) : 0)); diff --git a/apps/server/src/modules/meta-tag-extractor/service/meta-tag-internal-url.service.spec.ts b/apps/server/src/modules/meta-tag-extractor/service/meta-tag-internal-url.service.spec.ts new file mode 100644 index 00000000000..04d2f8b0c77 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/meta-tag-internal-url.service.spec.ts @@ -0,0 +1,136 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { Test, TestingModule } from '@nestjs/testing'; +import { setupEntities } from '@shared/testing'; +import { MetaData } from '../types'; +import { MetaTagExtractorService } from './meta-tag-extractor.service'; +import { MetaTagInternalUrlService } from './meta-tag-internal-url.service'; +import { BoardUrlHandler, CourseUrlHandler, LessonUrlHandler, TaskUrlHandler } from './url-handler'; + +const INTERNAL_DOMAIN = 'my-school-cloud.org'; +const INTERNAL_URL = `https://${INTERNAL_DOMAIN}/my-article`; +const UNKNOWN_INTERNAL_URL = `https://${INTERNAL_DOMAIN}/playground/23hafe23234`; +const EXTERNAL_URL = 'https://de.wikipedia.org/example-article'; + +describe(MetaTagExtractorService.name, () => { + let module: TestingModule; + let taskUrlHandler: DeepMocked; + let lessonUrlHandler: DeepMocked; + let courseUrlHandler: DeepMocked; + let boardUrlHandler: DeepMocked; + let service: MetaTagInternalUrlService; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + MetaTagInternalUrlService, + { + provide: TaskUrlHandler, + useValue: createMock(), + }, + { + provide: LessonUrlHandler, + useValue: createMock(), + }, + { + provide: CourseUrlHandler, + useValue: createMock(), + }, + { + provide: BoardUrlHandler, + useValue: createMock(), + }, + ], + }).compile(); + + taskUrlHandler = module.get(TaskUrlHandler); + lessonUrlHandler = module.get(LessonUrlHandler); + courseUrlHandler = module.get(CourseUrlHandler); + boardUrlHandler = module.get(BoardUrlHandler); + service = module.get(MetaTagInternalUrlService); + await setupEntities(); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => {}); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('isInternalUrl', () => { + const setup = () => { + Configuration.set('SC_DOMAIN', INTERNAL_DOMAIN); + taskUrlHandler.doesUrlMatch.mockReturnValueOnce(false); + lessonUrlHandler.doesUrlMatch.mockReturnValueOnce(false); + courseUrlHandler.doesUrlMatch.mockReturnValueOnce(false); + boardUrlHandler.doesUrlMatch.mockReturnValueOnce(false); + }; + + it('should return true for internal urls', () => { + setup(); + + expect(service.isInternalUrl(INTERNAL_URL)).toBe(true); + }); + + it('should return false for external urls', () => { + setup(); + + expect(service.isInternalUrl(EXTERNAL_URL)).toBe(false); + }); + }); + + describe('tryInternalLinkMetaTags', () => { + const setup = () => { + Configuration.set('SC_DOMAIN', INTERNAL_DOMAIN); + taskUrlHandler.doesUrlMatch.mockReturnValueOnce(false); + lessonUrlHandler.doesUrlMatch.mockReturnValueOnce(false); + boardUrlHandler.doesUrlMatch.mockReturnValueOnce(false); + const mockedMetaTags: MetaData = { + title: 'My Title', + url: INTERNAL_URL, + description: '', + type: 'course', + }; + + return { mockedMetaTags }; + }; + + describe('when url matches to a handler', () => { + it('should return the handlers meta tags', async () => { + const { mockedMetaTags } = setup(); + courseUrlHandler.doesUrlMatch.mockReturnValueOnce(true); + courseUrlHandler.getMetaData.mockResolvedValueOnce(mockedMetaTags); + + const result = await service.tryInternalLinkMetaTags(INTERNAL_URL); + + expect(result).toEqual(mockedMetaTags); + }); + }); + + describe('when url matches to none of the handlers', () => { + it('should return default meta tags', async () => { + setup(); + courseUrlHandler.doesUrlMatch.mockReturnValueOnce(false); + + const result = await service.tryInternalLinkMetaTags(UNKNOWN_INTERNAL_URL); + + expect(result).toEqual(expect.objectContaining({ type: 'unknown' })); + }); + }); + + describe('when url is external', () => { + it('should return undefined', async () => { + setup(); + courseUrlHandler.doesUrlMatch.mockReturnValueOnce(false); + + const result = await service.tryInternalLinkMetaTags(EXTERNAL_URL); + + expect(result).toBeUndefined(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/meta-tag-extractor/service/meta-tag-internal-url.service.ts b/apps/server/src/modules/meta-tag-extractor/service/meta-tag-internal-url.service.ts new file mode 100644 index 00000000000..5c0d5efca5c --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/meta-tag-internal-url.service.ts @@ -0,0 +1,51 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { Injectable } from '@nestjs/common'; +import type { UrlHandler } from '../interface/url-handler'; +import { MetaData } from '../types'; +import { BoardUrlHandler, CourseUrlHandler, LessonUrlHandler, TaskUrlHandler } from './url-handler'; + +@Injectable() +export class MetaTagInternalUrlService { + private handlers: UrlHandler[] = []; + + constructor( + private readonly taskUrlHandler: TaskUrlHandler, + private readonly lessonUrlHandler: LessonUrlHandler, + private readonly courseUrlHandler: CourseUrlHandler, + private readonly boardUrlHandler: BoardUrlHandler + ) { + this.handlers = [this.taskUrlHandler, this.lessonUrlHandler, this.courseUrlHandler, this.boardUrlHandler]; + } + + async tryInternalLinkMetaTags(url: string): Promise { + if (this.isInternalUrl(url)) { + return this.composeMetaTags(url); + } + return Promise.resolve(undefined); + } + + isInternalUrl(url: string) { + let domain = Configuration.get('SC_DOMAIN') as string; + domain = domain === '' ? 'nothing-configured-for-internal-url.de' : domain; + const isInternal = url.toLowerCase().includes(domain.toLowerCase()); + return isInternal; + } + + private async composeMetaTags(url: string): Promise { + const urlObject = new URL(url); + + const handler = this.handlers.find((h) => h.doesUrlMatch(url)); + if (handler) { + const result = await handler.getMetaData(url); + return result; + } + + const title = urlObject.pathname; + return Promise.resolve({ + title, + description: '', + url, + type: 'unknown', + }); + } +} diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/abstract-url-handler.spec.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/abstract-url-handler.spec.ts new file mode 100644 index 00000000000..b6900cfd492 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/abstract-url-handler.spec.ts @@ -0,0 +1,59 @@ +import { AbstractUrlHandler } from './abstract-url-handler'; + +class DummyHandler extends AbstractUrlHandler { + patterns: RegExp[] = [/\/dummy\/([0-9a-z]+)$/i]; + + extractId(url: string): string | undefined { + return super.extractId(url); + } +} + +describe(AbstractUrlHandler.name, () => { + const setup = () => { + const id = 'af322312feae'; + const url = `https://localhost/dummy/${id}`; + const invalidUrl = `https://localhost/wrong/${id}`; + const handler = new DummyHandler(); + return { id, url, invalidUrl, handler }; + }; + + describe('extractId', () => { + describe('when no id was extracted', () => { + it('should return undefined', () => { + const { invalidUrl, handler } = setup(); + + const result = handler.extractId(invalidUrl); + + expect(result).toBeUndefined(); + }); + }); + }); + + describe('doesUrlMatch', () => { + it('should be true for valid urls', () => { + const { url, handler } = setup(); + + const result = handler.doesUrlMatch(url); + + expect(result).toBe(true); + }); + + it('should be false for invalid urls', () => { + const { invalidUrl, handler } = setup(); + + const result = handler.doesUrlMatch(invalidUrl); + + expect(result).toBe(false); + }); + }); + + describe('getDefaultMetaData', () => { + it('should return meta data of type unknown', () => { + const { url, handler } = setup(); + + const result = handler.getDefaultMetaData(url); + + expect(result).toEqual(expect.objectContaining({ type: 'unknown', url })); + }); + }); +}); diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/abstract-url-handler.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/abstract-url-handler.ts new file mode 100644 index 00000000000..fb618c3bf36 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/abstract-url-handler.ts @@ -0,0 +1,35 @@ +import { basename } from 'node:path'; +import { MetaData } from '../../types'; + +export abstract class AbstractUrlHandler { + protected abstract patterns: RegExp[]; + + protected extractId(url: string): string | undefined { + const results: RegExpMatchArray = this.patterns + .map((pattern: RegExp) => pattern.exec(url)) + .filter((result) => result !== null) + .find((result) => (result?.length ?? 0) >= 2) as RegExpMatchArray; + + if (results && results[1]) { + return results[1]; + } + return undefined; + } + + doesUrlMatch(url: string): boolean { + const doesMatch = this.patterns.some((pattern) => pattern.test(url)); + return doesMatch; + } + + getDefaultMetaData(url: string, partial: Partial = {}): MetaData { + const urlObject = new URL(url); + const title = basename(urlObject.pathname); + return { + title, + description: '', + url, + type: 'unknown', + ...partial, + }; + } +} diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.spec.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.spec.ts new file mode 100644 index 00000000000..f7775b58f1f --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.spec.ts @@ -0,0 +1,70 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ColumnBoardService } from '@modules/board'; +import { CourseService } from '@modules/learnroom'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ColumnBoard } from '@shared/domain'; +import { setupEntities } from '@shared/testing'; +import { BoardUrlHandler } from './board-url-handler'; + +describe(BoardUrlHandler.name, () => { + let module: TestingModule; + let columnBoardService: DeepMocked; + let boardUrlHandler: BoardUrlHandler; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + BoardUrlHandler, + { + provide: ColumnBoardService, + useValue: createMock(), + }, + { + provide: CourseService, + useValue: createMock(), + }, + ], + }).compile(); + + columnBoardService = module.get(ColumnBoardService); + boardUrlHandler = module.get(BoardUrlHandler); + await setupEntities(); + }); + + describe('getMetaData', () => { + describe('when url fits', () => { + it('should call courseService with the correct id', async () => { + const id = 'af322312feae'; + const url = `https://localhost/rooms/${id}/board`; + + await boardUrlHandler.getMetaData(url); + + expect(columnBoardService.findById).toHaveBeenCalledWith(id); + }); + + it('should take the title from the board name', async () => { + const id = 'af322312feae'; + const url = `https://localhost/rooms/${id}/board`; + const boardName = 'My Board'; + columnBoardService.findById.mockResolvedValue({ + title: boardName, + context: { type: 'course', id: 'a-board-id' }, + } as ColumnBoard); + + const result = await boardUrlHandler.getMetaData(url); + + expect(result).toEqual(expect.objectContaining({ title: boardName, type: 'board' })); + }); + }); + + describe('when url does not fit', () => { + it('should return undefined', async () => { + const url = `https://localhost/invalid/ef2345abe4e3b`; + + const result = await boardUrlHandler.getMetaData(url); + + expect(result).toBeUndefined(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.ts new file mode 100644 index 00000000000..013631244dd --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.ts @@ -0,0 +1,37 @@ +import { ColumnBoardService } from '@modules/board'; +import { CourseService } from '@modules/learnroom'; +import { Injectable } from '@nestjs/common'; +import { BoardExternalReferenceType } from '@shared/domain'; +import type { UrlHandler } from '../../interface/url-handler'; +import { MetaData } from '../../types'; +import { AbstractUrlHandler } from './abstract-url-handler'; + +@Injectable() +export class BoardUrlHandler extends AbstractUrlHandler implements UrlHandler { + patterns: RegExp[] = [/\/rooms\/(.*?)\/board\/?$/i]; + + constructor(private readonly columnBoardService: ColumnBoardService, private readonly courseService: CourseService) { + super(); + } + + async getMetaData(url: string): Promise { + const id = this.extractId(url); + if (id === undefined) { + return undefined; + } + + const metaData = this.getDefaultMetaData(url, { type: 'board' }); + + const columnBoard = await this.columnBoardService.findById(id); + if (columnBoard) { + metaData.title = columnBoard.title; + if (columnBoard.context.type === BoardExternalReferenceType.Course) { + const course = await this.courseService.findById(columnBoard.context.id); + metaData.parentType = 'course'; + metaData.parentTitle = course.name; + } + } + + return metaData; + } +} diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.spec.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.spec.ts new file mode 100644 index 00000000000..75a43876de4 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.spec.ts @@ -0,0 +1,62 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { CourseService } from '@modules/learnroom'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Course } from '@shared/domain'; +import { setupEntities } from '@shared/testing'; +import { CourseUrlHandler } from './course-url-handler'; + +describe(CourseUrlHandler.name, () => { + let module: TestingModule; + let courseService: DeepMocked; + let courseUrlHandler: CourseUrlHandler; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + CourseUrlHandler, + { + provide: CourseService, + useValue: createMock(), + }, + ], + }).compile(); + + courseService = module.get(CourseService); + courseUrlHandler = module.get(CourseUrlHandler); + await setupEntities(); + }); + + describe('getMetaData', () => { + describe('when url fits', () => { + it('should call courseService with the correct id', async () => { + const id = 'af322312feae'; + const url = `https://localhost/rooms/${id}`; + + await courseUrlHandler.getMetaData(url); + + expect(courseService.findById).toHaveBeenCalledWith(id); + }); + + it('should take the title from the course name', async () => { + const id = 'af322312feae'; + const url = `https://localhost/rooms/${id}`; + const courseName = 'My Course'; + courseService.findById.mockResolvedValue({ name: courseName } as Course); + + const result = await courseUrlHandler.getMetaData(url); + + expect(result).toEqual(expect.objectContaining({ title: courseName, type: 'course' })); + }); + }); + + describe('when url does not fit', () => { + it('should return undefined', async () => { + const url = `https://localhost/invalid/ef2345abe4e3b`; + + const result = await courseUrlHandler.getMetaData(url); + + expect(result).toBeUndefined(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.ts new file mode 100644 index 00000000000..def041886f1 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.ts @@ -0,0 +1,29 @@ +import { CourseService } from '@modules/learnroom'; +import { Injectable } from '@nestjs/common'; +import type { UrlHandler } from '../../interface/url-handler'; +import { MetaData } from '../../types'; +import { AbstractUrlHandler } from './abstract-url-handler'; + +@Injectable() +export class CourseUrlHandler extends AbstractUrlHandler implements UrlHandler { + patterns: RegExp[] = [/\/rooms\/([0-9a-z]+)$/i]; + + constructor(private readonly courseService: CourseService) { + super(); + } + + async getMetaData(url: string): Promise { + const id = this.extractId(url); + if (id === undefined) { + return undefined; + } + + const metaData = this.getDefaultMetaData(url, { type: 'course' }); + const course = await this.courseService.findById(id); + if (course) { + metaData.title = course.name; + } + + return metaData; + } +} diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/index.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/index.ts new file mode 100644 index 00000000000..a29b8401da2 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/index.ts @@ -0,0 +1,4 @@ +export * from './board-url-handler'; +export * from './course-url-handler'; +export * from './lesson-url-handler'; +export * from './task-url-handler'; diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/lesson-url-handler.spec.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/lesson-url-handler.spec.ts new file mode 100644 index 00000000000..53b59d86ab7 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/lesson-url-handler.spec.ts @@ -0,0 +1,62 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { LessonService } from '@modules/lesson'; +import { Test, TestingModule } from '@nestjs/testing'; +import { LessonEntity } from '@shared/domain'; +import { setupEntities } from '@shared/testing'; +import { LessonUrlHandler } from './lesson-url-handler'; + +describe(LessonUrlHandler.name, () => { + let module: TestingModule; + let lessonService: DeepMocked; + let lessonUrlHandler: LessonUrlHandler; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + LessonUrlHandler, + { + provide: LessonService, + useValue: createMock(), + }, + ], + }).compile(); + + lessonService = module.get(LessonService); + lessonUrlHandler = module.get(LessonUrlHandler); + await setupEntities(); + }); + + describe('getMetaData', () => { + describe('when url fits', () => { + it('should call lessonService with the correct id', async () => { + const id = 'af322312feae'; + const url = `https://localhost/topics/${id}`; + + await lessonUrlHandler.getMetaData(url); + + expect(lessonService.findById).toHaveBeenCalledWith(id); + }); + + it('should take the title from the lessons name', async () => { + const id = 'af322312feae'; + const url = `https://localhost/topics/${id}`; + const lessonName = 'My lesson'; + lessonService.findById.mockResolvedValue({ name: lessonName } as LessonEntity); + + const result = await lessonUrlHandler.getMetaData(url); + + expect(result).toEqual(expect.objectContaining({ title: lessonName, type: 'lesson' })); + }); + }); + + describe('when url does not fit', () => { + it('should return undefined', async () => { + const url = `https://localhost/invalid/ef2345abe4e3b`; + + const result = await lessonUrlHandler.getMetaData(url); + + expect(result).toBeUndefined(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/lesson-url-handler.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/lesson-url-handler.ts new file mode 100644 index 00000000000..c5264020a50 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/lesson-url-handler.ts @@ -0,0 +1,29 @@ +import { LessonService } from '@modules/lesson'; +import { Injectable } from '@nestjs/common'; +import type { UrlHandler } from '../../interface/url-handler'; +import { MetaData } from '../../types'; +import { AbstractUrlHandler } from './abstract-url-handler'; + +@Injectable() +export class LessonUrlHandler extends AbstractUrlHandler implements UrlHandler { + patterns: RegExp[] = [/\/topics\/([0-9a-z]+)$/i]; + + constructor(private readonly lessonService: LessonService) { + super(); + } + + async getMetaData(url: string): Promise { + const id = this.extractId(url); + if (id === undefined) { + return undefined; + } + + const metaData = this.getDefaultMetaData(url, { type: 'lesson' }); + const lesson = await this.lessonService.findById(id); + if (lesson) { + metaData.title = lesson.name; + } + + return metaData; + } +} diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/task-url-handler.spec.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/task-url-handler.spec.ts new file mode 100644 index 00000000000..0423382f2a8 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/task-url-handler.spec.ts @@ -0,0 +1,62 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { TaskService } from '@modules/task'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Task } from '@shared/domain'; +import { setupEntities } from '@shared/testing'; +import { TaskUrlHandler } from './task-url-handler'; + +describe(TaskUrlHandler.name, () => { + let module: TestingModule; + let taskService: DeepMocked; + let taskUrlHandler: TaskUrlHandler; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + TaskUrlHandler, + { + provide: TaskService, + useValue: createMock(), + }, + ], + }).compile(); + + taskService = module.get(TaskService); + taskUrlHandler = module.get(TaskUrlHandler); + await setupEntities(); + }); + + describe('getMetaData', () => { + describe('when url fits', () => { + it('should call taskService with the correct id', async () => { + const id = 'af322312feae'; + const url = `https://localhost/homework/${id}`; + + await taskUrlHandler.getMetaData(url); + + expect(taskService.findById).toHaveBeenCalledWith(id); + }); + + it('should take the title from the tasks name', async () => { + const id = 'af322312feae'; + const url = `https://localhost/homework/${id}`; + const taskName = 'My Task'; + taskService.findById.mockResolvedValue({ name: taskName } as Task); + + const result = await taskUrlHandler.getMetaData(url); + + expect(result).toEqual(expect.objectContaining({ title: taskName, type: 'task' })); + }); + }); + + describe('when url does not fit', () => { + it('should return undefined', async () => { + const url = `https://localhost/invalid/ef2345abe4e3b`; + + const result = await taskUrlHandler.getMetaData(url); + + expect(result).toBeUndefined(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/task-url-handler.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/task-url-handler.ts new file mode 100644 index 00000000000..cb1cec86048 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/task-url-handler.ts @@ -0,0 +1,29 @@ +import { TaskService } from '@modules/task'; +import { Injectable } from '@nestjs/common'; +import type { UrlHandler } from '../../interface/url-handler'; +import { MetaData } from '../../types'; +import { AbstractUrlHandler } from './abstract-url-handler'; + +@Injectable() +export class TaskUrlHandler extends AbstractUrlHandler implements UrlHandler { + patterns: RegExp[] = [/\/homework\/([0-9a-z]+)$/i]; + + constructor(private readonly taskService: TaskService) { + super(); + } + + async getMetaData(url: string): Promise { + const id = this.extractId(url); + if (id === undefined) { + return undefined; + } + + const metaData = this.getDefaultMetaData(url, { type: 'task' }); + const task = await this.taskService.findById(id); + if (task) { + metaData.title = task.name; + } + + return metaData; + } +} diff --git a/apps/server/src/modules/meta-tag-extractor/types/index.ts b/apps/server/src/modules/meta-tag-extractor/types/index.ts new file mode 100644 index 00000000000..776e417867e --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/types/index.ts @@ -0,0 +1 @@ +export * from './meta-data.type'; diff --git a/apps/server/src/modules/meta-tag-extractor/types/meta-data.type.ts b/apps/server/src/modules/meta-tag-extractor/types/meta-data.type.ts new file mode 100644 index 00000000000..b4da460d6e9 --- /dev/null +++ b/apps/server/src/modules/meta-tag-extractor/types/meta-data.type.ts @@ -0,0 +1,13 @@ +import { ImageObject } from 'open-graph-scraper/dist/lib/types'; + +export type MetaDataEntityType = 'external' | 'course' | 'board' | 'task' | 'lesson' | 'unknown'; + +export type MetaData = { + title: string; + description: string; + url: string; + image?: ImageObject; + type: MetaDataEntityType; + parentTitle?: string; + parentType?: MetaDataEntityType; +}; diff --git a/apps/server/src/modules/meta-tag-extractor/uc/meta-tag-extractor.uc.spec.ts b/apps/server/src/modules/meta-tag-extractor/uc/meta-tag-extractor.uc.spec.ts index 118b7d82633..f5aa0c6cd72 100644 --- a/apps/server/src/modules/meta-tag-extractor/uc/meta-tag-extractor.uc.spec.ts +++ b/apps/server/src/modules/meta-tag-extractor/uc/meta-tag-extractor.uc.spec.ts @@ -1,8 +1,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AuthorizationService } from '@modules/authorization'; import { UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { setupEntities, userFactory } from '@shared/testing'; -import { AuthorizationService } from '@src/modules/authorization'; import { MetaTagExtractorService } from '../service'; import { MetaTagExtractorUc } from './meta-tag-extractor.uc'; @@ -42,7 +42,7 @@ describe(MetaTagExtractorUc.name, () => { jest.resetAllMocks(); }); - describe('fetchMetaData', () => { + describe('getMetaData', () => { describe('when user exists', () => { const setup = () => { const user = userFactory.build(); @@ -57,7 +57,7 @@ describe(MetaTagExtractorUc.name, () => { authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); const url = 'https://www.example.com/great-example'; - await uc.fetchMetaData(user.id, url); + await uc.getMetaData(user.id, url); expect(authorizationService.getUserWithPermissions).toHaveBeenCalledWith(user.id); }); @@ -66,9 +66,9 @@ describe(MetaTagExtractorUc.name, () => { const { user } = setup(); const url = 'https://www.example.com/great-example'; - await uc.fetchMetaData(user.id, url); + await uc.getMetaData(user.id, url); - expect(metaTagExtractorService.fetchMetaData).toHaveBeenCalledWith(url); + expect(metaTagExtractorService.getMetaData).toHaveBeenCalledWith(url); }); }); @@ -84,7 +84,7 @@ describe(MetaTagExtractorUc.name, () => { const { user } = setup(); const url = 'https://www.example.com/great-example'; - await expect(uc.fetchMetaData(user.id, url)).rejects.toThrow(UnauthorizedException); + await expect(uc.getMetaData(user.id, url)).rejects.toThrow(UnauthorizedException); }); }); }); diff --git a/apps/server/src/modules/meta-tag-extractor/uc/meta-tag-extractor.uc.ts b/apps/server/src/modules/meta-tag-extractor/uc/meta-tag-extractor.uc.ts index 5daca6c962d..47ac53d88b0 100644 --- a/apps/server/src/modules/meta-tag-extractor/uc/meta-tag-extractor.uc.ts +++ b/apps/server/src/modules/meta-tag-extractor/uc/meta-tag-extractor.uc.ts @@ -1,7 +1,8 @@ +import { AuthorizationService } from '@modules/authorization'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { EntityId } from '@shared/domain'; -import { AuthorizationService } from '@src/modules/authorization'; -import { MetaData, MetaTagExtractorService } from '../service'; +import { MetaTagExtractorService } from '../service'; +import { MetaData } from '../types'; @Injectable() export class MetaTagExtractorUc { @@ -10,14 +11,14 @@ export class MetaTagExtractorUc { private readonly metaTagExtractorService: MetaTagExtractorService ) {} - async fetchMetaData(userId: EntityId, url: string): Promise { + async getMetaData(userId: EntityId, url: string): Promise { try { await this.authorizationService.getUserWithPermissions(userId); } catch (error) { throw new UnauthorizedException(); } - const result = await this.metaTagExtractorService.fetchMetaData(url); + const result = await this.metaTagExtractorService.getMetaData(url); return result; } }