From 42c3a9639071544b3b694eb255a6a3d94dcf02e1 Mon Sep 17 00:00:00 2001 From: Igor Richter Date: Thu, 2 May 2024 23:17:35 +0200 Subject: [PATCH 01/11] poc refactoring getAllGroups --- .../group/controller/group.controller.ts | 18 +++- .../src/modules/group/repo/group.repo.ts | 93 ++++++++++++++++++- .../modules/group/service/group.service.ts | 14 ++- apps/server/src/modules/group/uc/group.uc.ts | 37 +++----- .../shared/domain/interface/find-options.ts | 13 +++ 5 files changed, 147 insertions(+), 28 deletions(-) diff --git a/apps/server/src/modules/group/controller/group.controller.ts b/apps/server/src/modules/group/controller/group.controller.ts index e19f867c394..3372cc94241 100644 --- a/apps/server/src/modules/group/controller/group.controller.ts +++ b/apps/server/src/modules/group/controller/group.controller.ts @@ -1,9 +1,10 @@ import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { Group } from '@modules/group'; import { Controller, ForbiddenException, Get, HttpStatus, Param, Query, UnauthorizedException } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiValidationError } from '@shared/common'; import { Page } from '@shared/domain/domainobject'; -import { IFindQuery } from '@shared/domain/interface'; +import { IFindOptions, IGroupFilter } from '@shared/domain/interface'; import { ErrorResponse } from '@src/core/error/dto'; import { GroupUc } from '../uc'; import { ClassInfoDto, ResolvedGroupDto } from '../uc/dto'; @@ -86,13 +87,22 @@ export class GroupController { @Query() pagination: GroupPaginationParams, @Query() params: GroupParams ): Promise { - const query: IFindQuery = { pagination, nameQuery: params.nameQuery }; + const options: IFindOptions = { pagination }; + // set userId and schoolId in UC? + const filter: IGroupFilter = { + userId: currentUser.userId, + schoolId: currentUser.schoolId, + nameQuery: params.nameQuery, + availableGroupsForCourseSync: params.availableGroupsForCourseSync, + }; + const groups: Page = await this.groupUc.getAllGroups( currentUser.userId, currentUser.schoolId, - query, - params.availableGroupsForCourseSync + filter, + options ); + const response: GroupListResponse = GroupResponseMapper.mapToGroupListResponse(groups, pagination); return response; diff --git a/apps/server/src/modules/group/repo/group.repo.ts b/apps/server/src/modules/group/repo/group.repo.ts index f10321eb5b5..2f12ed06f2c 100644 --- a/apps/server/src/modules/group/repo/group.repo.ts +++ b/apps/server/src/modules/group/repo/group.repo.ts @@ -4,7 +4,7 @@ import { School } from '@modules/school'; import { Injectable } from '@nestjs/common'; import { StringValidator } from '@shared/common'; import { Page, type UserDO } from '@shared/domain/domainobject'; -import { IFindQuery } from '@shared/domain/interface'; +import { IFindOptions, IFindQuery, IGroupFilter } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { MongoPatterns } from '@shared/repo'; import { BaseDomainObjectRepo } from '@shared/repo/base-domain-object.repo'; @@ -54,6 +54,7 @@ export class GroupRepo extends BaseDomainObjectRepo { return domainObject; } + // TODO N21-1860 delete when refactored to findGroups() public async findByUserAndGroupTypes( user: UserDO, groupTypes?: GroupTypes[], @@ -83,6 +84,7 @@ export class GroupRepo extends BaseDomainObjectRepo { return page; } + // TODO N21-1860 delete public async findAvailableByUser(user: UserDO, query?: IFindQuery): Promise> { const pipelineStage: unknown[] = [{ $match: { users: { $elemMatch: { user: new ObjectId(user.id) } } } }]; const availableGroups: Page = await this.findAvailableGroup( @@ -95,6 +97,7 @@ export class GroupRepo extends BaseDomainObjectRepo { return availableGroups; } + // TODO N21-1860 delete when refactored to findGroups() public async findBySchoolIdAndGroupTypes( school: School, groupTypes?: GroupTypes[], @@ -124,6 +127,7 @@ export class GroupRepo extends BaseDomainObjectRepo { return page; } + // TODO N21-1860 delete public async findAvailableBySchoolId(school: School, query?: IFindQuery): Promise> { const pipelineStage: unknown[] = [{ $match: { organization: new ObjectId(school.id) } }]; @@ -137,6 +141,7 @@ export class GroupRepo extends BaseDomainObjectRepo { return availableGroups; } + // TODO N21-1860 delete when refactored to findGroups(), pass optional systemId or use interface in provisioningOptionsService public async findGroupsBySchoolIdAndSystemIdAndGroupType( schoolId: EntityId, systemId: EntityId, @@ -156,6 +161,92 @@ export class GroupRepo extends BaseDomainObjectRepo { return domainObjects; } + public async findGroups(filter: IGroupFilter, options?: IFindOptions): Promise> { + const scope: GroupScope = new GroupScope(); + scope.byUserId(filter.userId); + scope.byOrganizationId(filter.schoolId); + scope.bySystemId(filter.systemId); + + if (filter.groupTypes) { + const groupEntityTypes = filter.groupTypes.map((type: GroupTypes) => GroupTypesToGroupEntityTypesMapping[type]); + scope.byTypes(groupEntityTypes); + } + + const escapedName = filter.nameQuery?.replace(MongoPatterns.REGEX_MONGO_LANGUAGE_PATTERN_WHITELIST, '').trim(); + if (StringValidator.isNotEmptyString(escapedName, true)) { + scope.byNameQuery(escapedName); + } + + const [entities, total] = await this.em.findAndCount(GroupEntity, scope.query, { + offset: options?.pagination?.skip, + limit: options?.pagination?.limit, + orderBy: { name: QueryOrder.ASC }, + }); + + const domainObjects: Group[] = entities.map((entity) => GroupDomainMapper.mapEntityToDo(entity)); + + const page: Page = new Page(domainObjects, total); + + return page; + } + + public async findAvailableGroups(filter: IGroupFilter, options?: IFindOptions): Promise> { + let nameRegexFilter = {}; + + const escapedName = filter.nameQuery?.replace(MongoPatterns.REGEX_MONGO_LANGUAGE_PATTERN_WHITELIST, '').trim(); + if (StringValidator.isNotEmptyString(escapedName, true)) { + nameRegexFilter = { name: { $regex: escapedName, $options: 'i' } }; + } + const pipeline: unknown[] = [ + { $match: { users: { $elemMatch: { user: new ObjectId(filter.userId) } } } }, + { $match: { organization: new ObjectId(filter.schoolId) } }, + { $match: nameRegexFilter }, + { + $lookup: { + from: 'courses', + localField: '_id', + foreignField: 'syncedWithGroup', + as: 'syncedCourses', + }, + }, + { $match: { syncedCourses: { $size: 0 } } }, + { $sort: { name: 1 } }, + ]; + + if (options?.pagination?.limit) { + pipeline.push({ + $facet: { + total: [{ $count: 'count' }], + data: [{ $skip: options.pagination.skip }, { $limit: options.pagination.limit }], + }, + }); + } else { + pipeline.push({ + $facet: { + total: [{ $count: 'count' }], + data: [{ $skip: options?.pagination?.skip }], + }, + }); + } + + const mongoEntitiesFacet = (await this.em.aggregate(GroupEntity, pipeline)) as [ + { total: [{ count: number }]; data: GroupEntity[] } + ]; + + const total: number = mongoEntitiesFacet[0]?.total[0]?.count ?? 0; + + const entities: GroupEntity[] = mongoEntitiesFacet[0].data.map((entity: GroupEntity) => + this.em.map(GroupEntity, entity) + ); + + const domainObjects: Group[] = entities.map((entity) => GroupDomainMapper.mapEntityToDo(entity)); + + const page: Page = new Page(domainObjects, total); + + return page; + } + + // TODO N21-1860 delete private async findAvailableGroup( pipelineStage: unknown[], skip = 0, diff --git a/apps/server/src/modules/group/service/group.service.ts b/apps/server/src/modules/group/service/group.service.ts index a91db81b6f1..c15110366b7 100644 --- a/apps/server/src/modules/group/service/group.service.ts +++ b/apps/server/src/modules/group/service/group.service.ts @@ -4,7 +4,7 @@ import { Injectable } from '@nestjs/common'; import { EventBus } from '@nestjs/cqrs'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { Page, type UserDO } from '@shared/domain/domainobject'; -import { IFindQuery } from '@shared/domain/interface'; +import { IFindOptions, IFindQuery, IGroupFilter } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { Group, GroupDeletedEvent, GroupTypes } from '../domain'; import { GroupRepo } from '../repo'; @@ -81,6 +81,18 @@ export class GroupService implements AuthorizationLoaderServiceGeneric { return group; } + public async findGroups(filter: IGroupFilter, options?: IFindOptions): Promise> { + const groups: Page = await this.groupRepo.findGroups(filter, options); + + return groups; + } + + public async findAvailableGroups(filter: IGroupFilter, options?: IFindOptions): Promise> { + const groups: Page = await this.groupRepo.findAvailableGroups(filter, options); + + return groups; + } + public async save(group: Group): Promise { const savedGroup: Group = await this.groupRepo.save(group); diff --git a/apps/server/src/modules/group/uc/group.uc.ts b/apps/server/src/modules/group/uc/group.uc.ts index 9b3dae9c272..1765fbb57d6 100644 --- a/apps/server/src/modules/group/uc/group.uc.ts +++ b/apps/server/src/modules/group/uc/group.uc.ts @@ -14,7 +14,7 @@ import { ConfigService } from '@nestjs/config'; import { SortHelper } from '@shared/common'; import { Page, UserDO } from '@shared/domain/domainobject'; import { SchoolYearEntity, User } from '@shared/domain/entity'; -import { IFindQuery, Permission, SortOrder } from '@shared/domain/interface'; +import { IFindOptions, IGroupFilter, Permission, SortOrder } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { Logger } from '@src/core/logger'; import { LegacySystemService, SystemDto } from '@src/modules/system'; @@ -367,8 +367,8 @@ export class GroupUc { public async getAllGroups( userId: EntityId, schoolId: EntityId, - query?: IFindQuery, - availableGroupsForCourseSync?: boolean + filter: IGroupFilter, + options?: IFindOptions ): Promise> { const school: School = await this.schoolService.getSchoolById(schoolId); @@ -379,9 +379,9 @@ export class GroupUc { let groups: Page; if (canSeeFullList) { - groups = await this.getGroupsForSchool(school, query, availableGroupsForCourseSync); + groups = await this.getGroupsForSchool(filter, options); } else { - groups = await this.getGroupsForUser(userId, query, availableGroupsForCourseSync); + groups = await this.getGroupsForUser(filter, options); } const resolvedGroups: ResolvedGroupDto[] = await Promise.all( @@ -398,32 +398,25 @@ export class GroupUc { return page; } - private async getGroupsForSchool( - school: School, - query?: IFindQuery, - availableGroupsForCourseSync?: boolean - ): Promise> { + private async getGroupsForSchool(filter: IGroupFilter, options?: IFindOptions): Promise> { + filter.userId = undefined; let foundGroups: Page; - if (availableGroupsForCourseSync && this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { - foundGroups = await this.groupService.findAvailableGroupsBySchoolId(school, query); + if (filter.availableGroupsForCourseSync && this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { + foundGroups = await this.groupService.findAvailableGroups(filter, options); } else { - foundGroups = await this.groupService.findGroupsBySchoolIdAndGroupTypes(school, undefined, query); + foundGroups = await this.groupService.findGroups(filter, options); } return foundGroups; } - private async getGroupsForUser( - userId: EntityId, - query?: IFindQuery, - availableGroupsForCourseSync?: boolean - ): Promise> { + private async getGroupsForUser(filter: IGroupFilter, options?: IFindOptions): Promise> { + filter.schoolId = undefined; let foundGroups: Page; - const user: UserDO = await this.userService.findById(userId); - if (availableGroupsForCourseSync && this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { - foundGroups = await this.groupService.findAvailableGroupsByUser(user, query); + if (filter.availableGroupsForCourseSync && this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { + foundGroups = await this.groupService.findAvailableGroups(filter, options); } else { - foundGroups = await this.groupService.findGroupsByUserAndGroupTypes(user, undefined, query); + foundGroups = await this.groupService.findGroups(filter, options); } return foundGroups; diff --git a/apps/server/src/shared/domain/interface/find-options.ts b/apps/server/src/shared/domain/interface/find-options.ts index 28822a35b0d..82ee2398041 100644 --- a/apps/server/src/shared/domain/interface/find-options.ts +++ b/apps/server/src/shared/domain/interface/find-options.ts @@ -1,3 +1,6 @@ +import { GroupTypes } from '@modules/group'; +import { EntityId } from '@shared/domain/types'; + export interface Pagination { skip?: number; limit?: number; @@ -15,9 +18,19 @@ export interface IFindOptions { order?: SortOrderMap; } +// TODO N21-1860 delete export interface IFindQuery { pagination?: Pagination; nameQuery?: string; } +export interface IGroupFilter { + userId?: EntityId; + schoolId?: EntityId; + groupTypes?: GroupTypes[]; + nameQuery?: string; + availableGroupsForCourseSync?: boolean; + systemId?: EntityId; +} + export type SortOrderNumberType = Partial>; From 80fdba551812a55bac02793d99ecc7fe5010777d Mon Sep 17 00:00:00 2001 From: Igor Richter Date: Fri, 3 May 2024 17:03:22 +0200 Subject: [PATCH 02/11] - use interface to generalize methods to find groups - repo tests - ToDo: service unit tests, uc unit tests, splitting groupUc --- .../group/controller/group.controller.ts | 14 +- apps/server/src/modules/group/domain/index.ts | 1 + .../group/domain/interface/group-filter.ts | 10 + .../modules/group/domain/interface/index.ts | 1 + .../src/modules/group/repo/group.repo.spec.ts | 431 ++++++++---------- .../src/modules/group/repo/group.repo.ts | 186 +------- .../modules/group/service/group.service.ts | 53 +-- apps/server/src/modules/group/uc/group.uc.ts | 66 +-- ...nex-provisioning-options-update.service.ts | 12 +- .../schulconnex-group-provisioning.service.ts | 5 +- .../shared/domain/interface/find-options.ts | 18 - 11 files changed, 261 insertions(+), 536 deletions(-) create mode 100644 apps/server/src/modules/group/domain/interface/group-filter.ts create mode 100644 apps/server/src/modules/group/domain/interface/index.ts diff --git a/apps/server/src/modules/group/controller/group.controller.ts b/apps/server/src/modules/group/controller/group.controller.ts index 3372cc94241..68333286746 100644 --- a/apps/server/src/modules/group/controller/group.controller.ts +++ b/apps/server/src/modules/group/controller/group.controller.ts @@ -4,7 +4,7 @@ import { Controller, ForbiddenException, Get, HttpStatus, Param, Query, Unauthor import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiValidationError } from '@shared/common'; import { Page } from '@shared/domain/domainobject'; -import { IFindOptions, IGroupFilter } from '@shared/domain/interface'; +import { IFindOptions } from '@shared/domain/interface'; import { ErrorResponse } from '@src/core/error/dto'; import { GroupUc } from '../uc'; import { ClassInfoDto, ResolvedGroupDto } from '../uc/dto'; @@ -88,19 +88,13 @@ export class GroupController { @Query() params: GroupParams ): Promise { const options: IFindOptions = { pagination }; - // set userId and schoolId in UC? - const filter: IGroupFilter = { - userId: currentUser.userId, - schoolId: currentUser.schoolId, - nameQuery: params.nameQuery, - availableGroupsForCourseSync: params.availableGroupsForCourseSync, - }; const groups: Page = await this.groupUc.getAllGroups( currentUser.userId, currentUser.schoolId, - filter, - options + options, + params.nameQuery, + params.availableGroupsForCourseSync ); const response: GroupListResponse = GroupResponseMapper.mapToGroupListResponse(groups, pagination); diff --git a/apps/server/src/modules/group/domain/index.ts b/apps/server/src/modules/group/domain/index.ts index 32b97d8f9f7..40845634539 100644 --- a/apps/server/src/modules/group/domain/index.ts +++ b/apps/server/src/modules/group/domain/index.ts @@ -2,3 +2,4 @@ export * from './group'; export * from './group-user'; export * from './group-types'; export { GroupDeletedEvent } from './event'; +export { IGroupFilter } from './interface'; diff --git a/apps/server/src/modules/group/domain/interface/group-filter.ts b/apps/server/src/modules/group/domain/interface/group-filter.ts new file mode 100644 index 00000000000..3e65e8edf61 --- /dev/null +++ b/apps/server/src/modules/group/domain/interface/group-filter.ts @@ -0,0 +1,10 @@ +import { GroupTypes } from '@modules/group'; +import { EntityId } from '@shared/domain/types'; + +export interface IGroupFilter { + userId?: EntityId; + schoolId?: EntityId; + systemId?: EntityId; + groupTypes?: GroupTypes[]; + nameQuery?: string; +} diff --git a/apps/server/src/modules/group/domain/interface/index.ts b/apps/server/src/modules/group/domain/interface/index.ts new file mode 100644 index 00000000000..d94a81d16f2 --- /dev/null +++ b/apps/server/src/modules/group/domain/interface/index.ts @@ -0,0 +1 @@ +export { IGroupFilter } from './group-filter'; diff --git a/apps/server/src/modules/group/repo/group.repo.spec.ts b/apps/server/src/modules/group/repo/group.repo.spec.ts index 2dce82334ad..f42f18bce33 100644 --- a/apps/server/src/modules/group/repo/group.repo.spec.ts +++ b/apps/server/src/modules/group/repo/group.repo.spec.ts @@ -1,10 +1,10 @@ import { MongoMemoryDatabaseModule } from '@infra/database'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { School } from '@modules/school'; -import { SchoolEntityMapper } from '@modules/school/repo/mikro-orm/mapper'; import { Test, TestingModule } from '@nestjs/testing'; -import { ExternalSource, Page, UserDO } from '@shared/domain/domainobject'; +import { ExternalSource, Page } from '@shared/domain/domainobject'; import { Course as CourseEntity, SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; +import { IFindOptions } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; import { cleanupCollections, courseFactory, @@ -13,7 +13,6 @@ import { roleFactory, schoolEntityFactory, systemEntityFactory, - userDoFactory, userFactory, } from '@shared/testing'; import { Group, GroupProps, GroupTypes, GroupUser } from '../domain'; @@ -95,11 +94,11 @@ describe('GroupRepo', () => { }); }); - describe('findByUserAndGroupTypes', () => { + describe('findGroups', () => { describe('when the user has groups', () => { const setup = async () => { const userEntity: User = userFactory.buildWithId(); - const user: UserDO = userDoFactory.build({ id: userEntity.id }); + const userId: EntityId = userEntity.id; const groups: GroupEntity[] = groupEntityFactory.buildListWithId(3, { users: [{ user: userEntity, role: roleFactory.buildWithId() }], }); @@ -114,20 +113,16 @@ describe('GroupRepo', () => { em.clear(); return { - user, + userId, groups, nameQuery, }; }; it('should return the groups', async () => { - const { user, groups } = await setup(); + const { userId, groups } = await setup(); - const result: Page = await repo.findByUserAndGroupTypes(user, [ - GroupTypes.CLASS, - GroupTypes.COURSE, - GroupTypes.OTHER, - ]); + const result: Page = await repo.findGroups({ userId }); expect(result.data.map((group) => group.id).sort((a, b) => a.localeCompare(b))).toEqual( groups.map((group) => group.id).sort((a, b) => a.localeCompare(b)) @@ -135,13 +130,9 @@ describe('GroupRepo', () => { }); it('should return groups according to pagination', async () => { - const { user, groups } = await setup(); + const { userId, groups } = await setup(); - const result: Page = await repo.findByUserAndGroupTypes( - user, - [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER], - { pagination: { skip: 1, limit: 1 } } - ); + const result: Page = await repo.findGroups({ userId }, { pagination: { skip: 1, limit: 1 } }); expect(result.total).toEqual(groups.length); expect(result.data.length).toEqual(1); @@ -149,43 +140,27 @@ describe('GroupRepo', () => { }); it('should return groups according to name query', async () => { - const { user, groups, nameQuery } = await setup(); + const { userId, groups, nameQuery } = await setup(); - const result: Page = await repo.findByUserAndGroupTypes( - user, - [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER], - { nameQuery } - ); + const result: Page = await repo.findGroups({ userId, nameQuery }); expect(result.data.length).toEqual(1); expect(result.data[0].id).toEqual(groups[1].id); }); it('should return only groups of the given group types', async () => { - const { user } = await setup(); + const { userId } = await setup(); - const result: Page = await repo.findByUserAndGroupTypes(user, [GroupTypes.CLASS]); + const result: Page = await repo.findGroups({ userId, groupTypes: [GroupTypes.CLASS] }); expect(result.data).toEqual([expect.objectContaining>({ type: GroupTypes.CLASS })]); }); - - describe('when no group type is given', () => { - it('should return all groups', async () => { - const { user, groups } = await setup(); - - const result: Page = await repo.findByUserAndGroupTypes(user); - - expect(result.data.map((group) => group.id).sort((a, b) => a.localeCompare(b))).toEqual( - groups.map((group) => group.id).sort((a, b) => a.localeCompare(b)) - ); - }); - }); }); - describe('when the user has no groups exists', () => { + describe('when the user has no groups', () => { const setup = async () => { const userEntity: User = userFactory.buildWithId(); - const user: UserDO = userDoFactory.build({ id: userEntity.id }); + const userId: EntityId = userEntity.id; const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2); @@ -193,276 +168,252 @@ describe('GroupRepo', () => { em.clear(); return { - user, + userId, }; }; it('should return an empty array', async () => { - const { user } = await setup(); + const { userId } = await setup(); - const result: Page = await repo.findByUserAndGroupTypes(user, [ - GroupTypes.CLASS, - GroupTypes.COURSE, - GroupTypes.OTHER, - ]); + const result: Page = await repo.findGroups({ userId }); expect(result.data).toHaveLength(0); }); }); - }); - describe('findAvailableByUser', () => { - describe('when the user has groups', () => { + describe('when groups for the school exist', () => { const setup = async () => { - const userEntity: User = userFactory.buildWithId(); - const user: UserDO = userDoFactory.build({ id: userEntity.id }); - const groupUserEntity: GroupUserEmbeddable = new GroupUserEmbeddable({ - user: userEntity, - role: roleFactory.buildWithId(), - }); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const schoolId: EntityId = school.id; const groups: GroupEntity[] = groupEntityFactory.buildListWithId(3, { - users: [groupUserEntity], + type: GroupEntityTypes.CLASS, + organization: school, }); - const nameQuery = groups[2].name.slice(-3); - const course: CourseEntity = courseFactory.build({ syncedWithGroup: groups[0] }); - const availableGroupsCount = 2; + groups[1].type = GroupEntityTypes.COURSE; + groups[2].type = GroupEntityTypes.OTHER; - const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2); + const nameQuery = groups[1].name.slice(-3); - await em.persistAndFlush([userEntity, ...groups, ...otherGroups, course]); + const otherSchool: SchoolEntity = schoolEntityFactory.buildWithId(); + const otherSchoolId: EntityId = otherSchool.id; + const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2, { + type: GroupEntityTypes.CLASS, + organization: otherSchool, + }); + + await em.persistAndFlush([school, ...groups, otherSchool, ...otherGroups]); em.clear(); return { - user, + otherSchoolId, groups, - availableGroupsCount, nameQuery, + schoolId, }; }; - it('should return the available groups', async () => { - const { user, availableGroupsCount } = await setup(); + it('should return the groups', async () => { + const { schoolId, groups } = await setup(); - const result: Page = await repo.findAvailableByUser(user); + const result: Page = await repo.findGroups({ schoolId }); - expect(result.total).toEqual(availableGroupsCount); - expect(result.data.every((group) => group.users[0].userId === user.id)).toEqual(true); + expect(result.data).toHaveLength(groups.length); + }); + + it('should not return groups from another school', async () => { + const { schoolId, otherSchoolId } = await setup(); + + const result: Page = await repo.findGroups({ schoolId }); + + expect(result.data.map((group) => group.organizationId)).not.toContain(otherSchoolId); }); it('should return groups according to pagination', async () => { - const { user, groups, availableGroupsCount } = await setup(); + const { schoolId, groups } = await setup(); - const result: Page = await repo.findAvailableByUser(user, { pagination: { skip: 1, limit: 1 } }); + const result: Page = await repo.findGroups({ schoolId }, { pagination: { skip: 1, limit: 1 } }); - expect(result.total).toEqual(availableGroupsCount); + expect(result.total).toEqual(groups.length); expect(result.data.length).toEqual(1); - expect(result.data[0].id).toEqual(groups[2].id); + expect(result.data[0].id).toEqual(groups[1].id); }); it('should return groups according to name query', async () => { - const { user, groups, nameQuery } = await setup(); + const { schoolId, groups, nameQuery } = await setup(); - const result: Page = await repo.findAvailableByUser(user, { nameQuery }); + const result: Page = await repo.findGroups({ schoolId, nameQuery }); expect(result.data.length).toEqual(1); - expect(result.data[0].id).toEqual(groups[2].id); + expect(result.data[0].id).toEqual(groups[1].id); + }); + + it('should return only groups of the given group types', async () => { + const { schoolId } = await setup(); + + const result: Page = await repo.findGroups({ schoolId, groupTypes: [GroupTypes.CLASS] }); + + expect(result.data).toEqual([expect.objectContaining>({ type: GroupTypes.CLASS })]); }); }); - describe('when the user has no groups exists', () => { + describe('when no group exists', () => { const setup = async () => { - const userEntity: User = userFactory.buildWithId(); - const user: UserDO = userDoFactory.build({ id: userEntity.id }); - - const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const schoolId: EntityId = school.id; - await em.persistAndFlush([userEntity, ...otherGroups]); + await em.persistAndFlush(school); em.clear(); return { - user, + schoolId, }; }; it('should return an empty array', async () => { - const { user } = await setup(); + const { schoolId } = await setup(); - const result: Page = await repo.findAvailableByUser(user); + const result: Page = await repo.findGroups({ schoolId }); - expect(result.total).toEqual(0); + expect(result.data).toHaveLength(0); }); }); - }); - describe('findBySchoolIdAndGroupTypes', () => { - describe('when groups for the school exist', () => { + describe('when groups for the school and system exist', () => { const setup = async () => { - const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const system: SystemEntity = systemEntityFactory.buildWithId(); + const systemId: EntityId = system.id; + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [system] }); + const schoolId: EntityId = school.id; const groups: GroupEntity[] = groupEntityFactory.buildListWithId(3, { type: GroupEntityTypes.CLASS, organization: school, + externalSource: { + system, + }, }); groups[1].type = GroupEntityTypes.COURSE; groups[2].type = GroupEntityTypes.OTHER; - const nameQuery = groups[1].name.slice(-3); - - const otherSchool: SchoolEntity = schoolEntityFactory.buildWithId(); + const otherSchool: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [system] }); const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2, { type: GroupEntityTypes.CLASS, organization: otherSchool, }); - const schoolDO: School = SchoolEntityMapper.mapToDo(school); - - await em.persistAndFlush([school, ...groups, otherSchool, ...otherGroups]); + await em.persistAndFlush([school, system, ...groups, otherSchool, ...otherGroups]); em.clear(); return { - otherSchool, + schoolId, + systemId, groups, - nameQuery, - schoolDO, }; }; it('should return the groups', async () => { - const { schoolDO, groups } = await setup(); - - const result: Page = await repo.findBySchoolIdAndGroupTypes(schoolDO, [ - GroupTypes.CLASS, - GroupTypes.COURSE, - GroupTypes.OTHER, - ]); - - expect(result.data).toHaveLength(groups.length); - }); - - it('should not return groups from another school', async () => { - const { schoolDO, otherSchool } = await setup(); + const { schoolId, systemId } = await setup(); - const result: Page = await repo.findBySchoolIdAndGroupTypes(schoolDO, [ - GroupTypes.CLASS, - GroupTypes.COURSE, - ]); + const result: Page = await repo.findGroups({ schoolId, systemId }); - expect(result.data.map((group) => group.organizationId)).not.toContain(otherSchool.id); + expect(result.total).toEqual(3); }); - it('should return groups according to pagination', async () => { - const { schoolDO, groups } = await setup(); + it('should only return groups from the selected school', async () => { + const { schoolId, systemId } = await setup(); - const result: Page = await repo.findBySchoolIdAndGroupTypes( - schoolDO, - [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER], - { pagination: { skip: 1, limit: 1 } } - ); + const result: Page = await repo.findGroups({ schoolId, systemId }); - expect(result.total).toEqual(groups.length); - expect(result.data.length).toEqual(1); - expect(result.data[0].id).toEqual(groups[1].id); + expect(result.data.every((group) => group.organizationId === schoolId)).toEqual(true); }); - it('should return groups according to name query', async () => { - const { schoolDO, groups, nameQuery } = await setup(); + it('should only return groups from the selected system', async () => { + const { schoolId, systemId } = await setup(); - const result: Page = await repo.findBySchoolIdAndGroupTypes( - schoolDO, - [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER], - { nameQuery } - ); + const result: Page = await repo.findGroups({ schoolId, systemId }); - expect(result.data.length).toEqual(1); - expect(result.data[0].id).toEqual(groups[1].id); + expect(result.data.every((group) => group.externalSource?.systemId === systemId)).toEqual(true); }); - it('should return only groups of the given group types', async () => { - const { schoolDO } = await setup(); + it('should return only groups of the given group type', async () => { + const { schoolId, systemId } = await setup(); - const result: Page = await repo.findBySchoolIdAndGroupTypes(schoolDO, [GroupTypes.CLASS]); + const result: Page = await repo.findGroups({ schoolId, systemId, groupTypes: [GroupTypes.CLASS] }); expect(result.data).toEqual([expect.objectContaining>({ type: GroupTypes.CLASS })]); }); - - describe('when no group type is given', () => { - it('should return all groups', async () => { - const { schoolDO, groups } = await setup(); - - const result: Page = await repo.findBySchoolIdAndGroupTypes(schoolDO); - - expect(result.data).toHaveLength(groups.length); - }); - }); }); describe('when no group exists', () => { const setup = async () => { const school: SchoolEntity = schoolEntityFactory.buildWithId(); - const schoolDO: School = SchoolEntityMapper.mapToDo(school); + const schoolId: EntityId = school.id; + const system: SystemEntity = systemEntityFactory.buildWithId(); + const systemId: EntityId = system.id; - await em.persistAndFlush(school); + await em.persistAndFlush([school, system]); em.clear(); return { - schoolDO, + schoolId, + systemId, }; }; it('should return an empty array', async () => { - const { schoolDO } = await setup(); + const { schoolId, systemId } = await setup(); - const result: Page = await repo.findBySchoolIdAndGroupTypes(schoolDO, [GroupTypes.CLASS]); + const result: Page = await repo.findGroups({ schoolId, systemId }); expect(result.data).toHaveLength(0); }); }); }); - describe('findAvailableBySchoolId', () => { - describe('when available groups for the school exist', () => { + describe('findAvailableGroups', () => { + describe('when the user has groups', () => { const setup = async () => { - const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const userEntity: User = userFactory.buildWithId(); + const userId: EntityId = userEntity.id; + const groupUserEntity: GroupUserEmbeddable = new GroupUserEmbeddable({ + user: userEntity, + role: roleFactory.buildWithId(), + }); const groups: GroupEntity[] = groupEntityFactory.buildListWithId(3, { - type: GroupEntityTypes.CLASS, - organization: school, + users: [groupUserEntity], }); const nameQuery = groups[2].name.slice(-3); - const course: CourseEntity = courseFactory.build({ school, syncedWithGroup: groups[0] }); + const course: CourseEntity = courseFactory.build({ syncedWithGroup: groups[0] }); const availableGroupsCount = 2; - const otherSchool: SchoolEntity = schoolEntityFactory.buildWithId(); - const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2, { - type: GroupEntityTypes.CLASS, - organization: otherSchool, - }); - - const schoolDO: School = SchoolEntityMapper.mapToDo(school); + const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2); - await em.persistAndFlush([school, ...groups, otherSchool, ...otherGroups, course]); + await em.persistAndFlush([userEntity, ...groups, ...otherGroups, course]); em.clear(); + const defaultOptions: IFindOptions = { pagination: { skip: 0 } }; + return { - schoolDO, - otherSchool, + userId, groups, availableGroupsCount, nameQuery, + defaultOptions, }; }; - it('should return the available groups from selected school', async () => { - const { schoolDO, availableGroupsCount } = await setup(); + it('should return the available groups', async () => { + const { userId, availableGroupsCount, defaultOptions } = await setup(); - const result: Page = await repo.findAvailableBySchoolId(schoolDO); + const result: Page = await repo.findAvailableGroups({ userId }, defaultOptions); - expect(result.data).toHaveLength(availableGroupsCount); - expect(result.data.every((group) => group.organizationId === schoolDO.id)).toEqual(true); + expect(result.total).toEqual(availableGroupsCount); + expect(result.data.every((group) => group.users[0].userId === userId)).toEqual(true); }); it('should return groups according to pagination', async () => { - const { schoolDO, groups, availableGroupsCount } = await setup(); + const { userId, groups, availableGroupsCount } = await setup(); - const result: Page = await repo.findAvailableBySchoolId(schoolDO, { pagination: { skip: 1, limit: 1 } }); + const result: Page = await repo.findAvailableGroups({ userId }, { pagination: { skip: 1, limit: 1 } }); expect(result.total).toEqual(availableGroupsCount); expect(result.data.length).toEqual(1); @@ -470,143 +421,125 @@ describe('GroupRepo', () => { }); it('should return groups according to name query', async () => { - const { schoolDO, groups, nameQuery } = await setup(); + const { userId, groups, nameQuery, defaultOptions } = await setup(); - const result: Page = await repo.findAvailableBySchoolId(schoolDO, { nameQuery }); + const result: Page = await repo.findAvailableGroups({ userId, nameQuery }, defaultOptions); expect(result.data.length).toEqual(1); expect(result.data[0].id).toEqual(groups[2].id); }); }); - describe('when no group exists', () => { + describe('when the user has no groups exists', () => { const setup = async () => { - const school: SchoolEntity = schoolEntityFactory.buildWithId(); - const schoolDO: School = SchoolEntityMapper.mapToDo(school); + const userEntity: User = userFactory.buildWithId(); + const userId: EntityId = userEntity.id; - await em.persistAndFlush([school]); + const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2); + + await em.persistAndFlush([userEntity, ...otherGroups]); em.clear(); + const defaultOptions: IFindOptions = { pagination: { skip: 0 } }; + return { - schoolDO, + userId, + defaultOptions, }; }; it('should return an empty array', async () => { - const { schoolDO } = await setup(); + const { userId, defaultOptions } = await setup(); - const result: Page = await repo.findAvailableBySchoolId(schoolDO); + const result: Page = await repo.findAvailableGroups({ userId }, defaultOptions); expect(result.total).toEqual(0); }); }); - }); - describe('findGroupsBySchoolIdAndSystemIdAndGroupType', () => { - describe('when groups for the school exist', () => { + describe('when available groups for the school exist', () => { const setup = async () => { - const system: SystemEntity = systemEntityFactory.buildWithId(); - const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [system] }); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const schoolId: EntityId = school.id; const groups: GroupEntity[] = groupEntityFactory.buildListWithId(3, { type: GroupEntityTypes.CLASS, organization: school, - externalSource: { - system, - }, }); - groups[1].type = GroupEntityTypes.COURSE; - groups[2].type = GroupEntityTypes.OTHER; + const nameQuery = groups[2].name.slice(-3); + const course: CourseEntity = courseFactory.build({ school, syncedWithGroup: groups[0] }); + const availableGroupsCount = 2; - const otherSchool: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [system] }); + const otherSchool: SchoolEntity = schoolEntityFactory.buildWithId(); const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2, { type: GroupEntityTypes.CLASS, organization: otherSchool, }); - await em.persistAndFlush([school, system, ...groups, otherSchool, ...otherGroups]); + await em.persistAndFlush([school, ...groups, otherSchool, ...otherGroups, course]); em.clear(); + const defaultOptions: IFindOptions = { pagination: { skip: 0 } }; + return { - school, - system, - otherSchool, + schoolId, groups, + availableGroupsCount, + nameQuery, + defaultOptions, }; }; - it('should return the groups', async () => { - const { school, system } = await setup(); - - const result: Group[] = await repo.findGroupsBySchoolIdAndSystemIdAndGroupType( - school.id, - system.id, - GroupTypes.CLASS - ); - - expect(result).toHaveLength(1); - }); - - it('should only return groups from the selected school', async () => { - const { school, system } = await setup(); + it('should return the available groups from selected school', async () => { + const { schoolId, availableGroupsCount, defaultOptions } = await setup(); - const result: Group[] = await repo.findGroupsBySchoolIdAndSystemIdAndGroupType( - school.id, - system.id, - GroupTypes.CLASS - ); + const result: Page = await repo.findAvailableGroups({ schoolId }, defaultOptions); - expect(result.every((group) => group.organizationId === school.id)).toEqual(true); + expect(result.data).toHaveLength(availableGroupsCount); + expect(result.data.every((group) => group.organizationId === schoolId)).toEqual(true); }); - it('should only return groups from the selected system', async () => { - const { school, system } = await setup(); + it('should return groups according to pagination', async () => { + const { schoolId, groups, availableGroupsCount } = await setup(); - const result: Group[] = await repo.findGroupsBySchoolIdAndSystemIdAndGroupType( - school.id, - system.id, - GroupTypes.CLASS - ); + const result: Page = await repo.findAvailableGroups({ schoolId }, { pagination: { skip: 1, limit: 1 } }); - expect(result.every((group) => group.externalSource?.systemId === system.id)).toEqual(true); + expect(result.total).toEqual(availableGroupsCount); + expect(result.data.length).toEqual(1); + expect(result.data[0].id).toEqual(groups[2].id); }); - it('should return only groups of the given group type', async () => { - const { school, system } = await setup(); + it('should return groups according to name query', async () => { + const { schoolId, groups, nameQuery, defaultOptions } = await setup(); - const result: Group[] = await repo.findGroupsBySchoolIdAndSystemIdAndGroupType( - school.id, - system.id, - GroupTypes.CLASS - ); + const result: Page = await repo.findAvailableGroups({ schoolId, nameQuery }, defaultOptions); - expect(result).toEqual([expect.objectContaining>({ type: GroupTypes.CLASS })]); + expect(result.data.length).toEqual(1); + expect(result.data[0].id).toEqual(groups[2].id); }); }); describe('when no group exists', () => { const setup = async () => { const school: SchoolEntity = schoolEntityFactory.buildWithId(); - const system: SystemEntity = systemEntityFactory.buildWithId(); + const schoolId: EntityId = school.id; - await em.persistAndFlush([school, system]); + await em.persistAndFlush([school]); em.clear(); + const defaultOptions: IFindOptions = { pagination: { skip: 0 } }; + return { - school, - system, + schoolId, + defaultOptions, }; }; it('should return an empty array', async () => { - const { school, system } = await setup(); + const { schoolId, defaultOptions } = await setup(); - const result: Group[] = await repo.findGroupsBySchoolIdAndSystemIdAndGroupType( - school.id, - system.id, - GroupTypes.CLASS - ); + const result: Page = await repo.findAvailableGroups({ schoolId }, defaultOptions); - expect(result).toHaveLength(0); + expect(result.total).toEqual(0); }); }); }); diff --git a/apps/server/src/modules/group/repo/group.repo.ts b/apps/server/src/modules/group/repo/group.repo.ts index 2f12ed06f2c..27fa748c260 100644 --- a/apps/server/src/modules/group/repo/group.repo.ts +++ b/apps/server/src/modules/group/repo/group.repo.ts @@ -1,15 +1,14 @@ import { EntityData, EntityName, QueryOrder } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; -import { School } from '@modules/school'; import { Injectable } from '@nestjs/common'; import { StringValidator } from '@shared/common'; -import { Page, type UserDO } from '@shared/domain/domainobject'; -import { IFindOptions, IFindQuery, IGroupFilter } from '@shared/domain/interface'; +import { Page } from '@shared/domain/domainobject'; +import { IFindOptions } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { MongoPatterns } from '@shared/repo'; import { BaseDomainObjectRepo } from '@shared/repo/base-domain-object.repo'; -import { Group, GroupTypes } from '../domain'; -import { GroupEntity, GroupEntityTypes } from '../entity'; +import { Group, GroupTypes, IGroupFilter } from '../domain'; +import { GroupEntity } from '../entity'; import { GroupDomainMapper, GroupTypesToGroupEntityTypesMapping } from './group-domain.mapper'; import { GroupScope } from './group.scope'; @@ -54,113 +53,6 @@ export class GroupRepo extends BaseDomainObjectRepo { return domainObject; } - // TODO N21-1860 delete when refactored to findGroups() - public async findByUserAndGroupTypes( - user: UserDO, - groupTypes?: GroupTypes[], - query?: IFindQuery - ): Promise> { - const scope: GroupScope = new GroupScope().byUserId(user.id); - if (groupTypes) { - const groupEntityTypes = groupTypes.map((type: GroupTypes) => GroupTypesToGroupEntityTypesMapping[type]); - scope.byTypes(groupEntityTypes); - } - - const escapedName = query?.nameQuery?.replace(MongoPatterns.REGEX_MONGO_LANGUAGE_PATTERN_WHITELIST, '').trim(); - if (StringValidator.isNotEmptyString(escapedName, true)) { - scope.byNameQuery(escapedName); - } - - const [entities, total] = await this.em.findAndCount(GroupEntity, scope.query, { - offset: query?.pagination?.skip, - limit: query?.pagination?.limit, - orderBy: { name: QueryOrder.ASC }, - }); - - const domainObjects: Group[] = entities.map((entity) => GroupDomainMapper.mapEntityToDo(entity)); - - const page: Page = new Page(domainObjects, total); - - return page; - } - - // TODO N21-1860 delete - public async findAvailableByUser(user: UserDO, query?: IFindQuery): Promise> { - const pipelineStage: unknown[] = [{ $match: { users: { $elemMatch: { user: new ObjectId(user.id) } } } }]; - const availableGroups: Page = await this.findAvailableGroup( - pipelineStage, - query?.pagination?.skip, - query?.pagination?.limit, - query?.nameQuery - ); - - return availableGroups; - } - - // TODO N21-1860 delete when refactored to findGroups() - public async findBySchoolIdAndGroupTypes( - school: School, - groupTypes?: GroupTypes[], - query?: IFindQuery - ): Promise> { - const scope: GroupScope = new GroupScope().byOrganizationId(school.id); - if (groupTypes) { - const groupEntityTypes = groupTypes.map((type: GroupTypes) => GroupTypesToGroupEntityTypesMapping[type]); - scope.byTypes(groupEntityTypes); - } - - const escapedName = query?.nameQuery?.replace(MongoPatterns.REGEX_MONGO_LANGUAGE_PATTERN_WHITELIST, '').trim(); - if (StringValidator.isNotEmptyString(escapedName, true)) { - scope.byNameQuery(escapedName); - } - - const [entities, total] = await this.em.findAndCount(GroupEntity, scope.query, { - offset: query?.pagination?.skip, - limit: query?.pagination?.limit, - orderBy: { name: QueryOrder.ASC }, - }); - - const domainObjects: Group[] = entities.map((entity) => GroupDomainMapper.mapEntityToDo(entity)); - - const page: Page = new Page(domainObjects, total); - - return page; - } - - // TODO N21-1860 delete - public async findAvailableBySchoolId(school: School, query?: IFindQuery): Promise> { - const pipelineStage: unknown[] = [{ $match: { organization: new ObjectId(school.id) } }]; - - const availableGroups: Page = await this.findAvailableGroup( - pipelineStage, - query?.pagination?.skip, - query?.pagination?.limit, - query?.nameQuery - ); - - return availableGroups; - } - - // TODO N21-1860 delete when refactored to findGroups(), pass optional systemId or use interface in provisioningOptionsService - public async findGroupsBySchoolIdAndSystemIdAndGroupType( - schoolId: EntityId, - systemId: EntityId, - groupType: GroupTypes - ): Promise { - const groupEntityType: GroupEntityTypes = GroupTypesToGroupEntityTypesMapping[groupType]; - - const scope: GroupScope = new GroupScope() - .byOrganizationId(schoolId) - .bySystemId(systemId) - .byTypes([groupEntityType]); - - const entities: GroupEntity[] = await this.em.find(GroupEntity, scope.query); - - const domainObjects: Group[] = entities.map((entity) => GroupDomainMapper.mapEntityToDo(entity)); - - return domainObjects; - } - public async findGroups(filter: IGroupFilter, options?: IFindOptions): Promise> { const scope: GroupScope = new GroupScope(); scope.byUserId(filter.userId); @@ -191,74 +83,20 @@ export class GroupRepo extends BaseDomainObjectRepo { } public async findAvailableGroups(filter: IGroupFilter, options?: IFindOptions): Promise> { + const pipeline: unknown[] = []; let nameRegexFilter = {}; const escapedName = filter.nameQuery?.replace(MongoPatterns.REGEX_MONGO_LANGUAGE_PATTERN_WHITELIST, '').trim(); if (StringValidator.isNotEmptyString(escapedName, true)) { nameRegexFilter = { name: { $regex: escapedName, $options: 'i' } }; } - const pipeline: unknown[] = [ - { $match: { users: { $elemMatch: { user: new ObjectId(filter.userId) } } } }, - { $match: { organization: new ObjectId(filter.schoolId) } }, - { $match: nameRegexFilter }, - { - $lookup: { - from: 'courses', - localField: '_id', - foreignField: 'syncedWithGroup', - as: 'syncedCourses', - }, - }, - { $match: { syncedCourses: { $size: 0 } } }, - { $sort: { name: 1 } }, - ]; - if (options?.pagination?.limit) { - pipeline.push({ - $facet: { - total: [{ $count: 'count' }], - data: [{ $skip: options.pagination.skip }, { $limit: options.pagination.limit }], - }, - }); - } else { - pipeline.push({ - $facet: { - total: [{ $count: 'count' }], - data: [{ $skip: options?.pagination?.skip }], - }, - }); + if (filter.userId) { + pipeline.push({ $match: { users: { $elemMatch: { user: new ObjectId(filter.userId) } } } }); } - const mongoEntitiesFacet = (await this.em.aggregate(GroupEntity, pipeline)) as [ - { total: [{ count: number }]; data: GroupEntity[] } - ]; - - const total: number = mongoEntitiesFacet[0]?.total[0]?.count ?? 0; - - const entities: GroupEntity[] = mongoEntitiesFacet[0].data.map((entity: GroupEntity) => - this.em.map(GroupEntity, entity) - ); - - const domainObjects: Group[] = entities.map((entity) => GroupDomainMapper.mapEntityToDo(entity)); - - const page: Page = new Page(domainObjects, total); - - return page; - } - - // TODO N21-1860 delete - private async findAvailableGroup( - pipelineStage: unknown[], - skip = 0, - limit?: number, - nameQuery?: string - ): Promise> { - let nameRegexFilter = {}; - const pipeline: unknown[] = pipelineStage; - - const escapedName = nameQuery?.replace(MongoPatterns.REGEX_MONGO_LANGUAGE_PATTERN_WHITELIST, '').trim(); - if (StringValidator.isNotEmptyString(escapedName, true)) { - nameRegexFilter = { name: { $regex: escapedName, $options: 'i' } }; + if (filter.schoolId) { + pipeline.push({ $match: { organization: new ObjectId(filter.schoolId) } }); } pipeline.push( @@ -275,18 +113,18 @@ export class GroupRepo extends BaseDomainObjectRepo { { $sort: { name: 1 } } ); - if (limit) { + if (options?.pagination?.limit) { pipeline.push({ $facet: { total: [{ $count: 'count' }], - data: [{ $skip: skip }, { $limit: limit }], + data: [{ $skip: options.pagination?.skip }, { $limit: options.pagination.limit }], }, }); } else { pipeline.push({ $facet: { total: [{ $count: 'count' }], - data: [{ $skip: skip }], + data: [{ $skip: options?.pagination?.skip }], }, }); } diff --git a/apps/server/src/modules/group/service/group.service.ts b/apps/server/src/modules/group/service/group.service.ts index c15110366b7..ae6d8711310 100644 --- a/apps/server/src/modules/group/service/group.service.ts +++ b/apps/server/src/modules/group/service/group.service.ts @@ -1,12 +1,11 @@ import { AuthorizationLoaderServiceGeneric } from '@modules/authorization'; -import { School } from '@modules/school'; import { Injectable } from '@nestjs/common'; import { EventBus } from '@nestjs/cqrs'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { Page, type UserDO } from '@shared/domain/domainobject'; -import { IFindOptions, IFindQuery, IGroupFilter } from '@shared/domain/interface'; +import { Page } from '@shared/domain/domainobject'; +import { IFindOptions } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { Group, GroupDeletedEvent, GroupTypes } from '../domain'; +import { Group, GroupDeletedEvent, IGroupFilter } from '../domain'; import { GroupRepo } from '../repo'; @Injectable() @@ -35,52 +34,6 @@ export class GroupService implements AuthorizationLoaderServiceGeneric { return group; } - public async findGroupsByUserAndGroupTypes( - user: UserDO, - groupTypes?: GroupTypes[], - query?: IFindQuery - ): Promise> { - const groups: Page = await this.groupRepo.findByUserAndGroupTypes(user, groupTypes, query); - - return groups; - } - - public async findAvailableGroupsByUser(user: UserDO, query?: IFindQuery): Promise> { - const groups: Page = await this.groupRepo.findAvailableByUser(user, query); - - return groups; - } - - public async findGroupsBySchoolIdAndGroupTypes( - school: School, - groupTypes?: GroupTypes[], - query?: IFindQuery - ): Promise> { - const group: Page = await this.groupRepo.findBySchoolIdAndGroupTypes(school, groupTypes, query); - - return group; - } - - public async findAvailableGroupsBySchoolId(school: School, query?: IFindQuery): Promise> { - const groups: Page = await this.groupRepo.findAvailableBySchoolId(school, query); - - return groups; - } - - public async findGroupsBySchoolIdAndSystemIdAndGroupType( - schoolId: EntityId, - systemId: EntityId, - groupType: GroupTypes - ): Promise { - const group: Group[] = await this.groupRepo.findGroupsBySchoolIdAndSystemIdAndGroupType( - schoolId, - systemId, - groupType - ); - - return group; - } - public async findGroups(filter: IGroupFilter, options?: IFindOptions): Promise> { const groups: Page = await this.groupRepo.findGroups(filter, options); diff --git a/apps/server/src/modules/group/uc/group.uc.ts b/apps/server/src/modules/group/uc/group.uc.ts index 1765fbb57d6..6f462c48283 100644 --- a/apps/server/src/modules/group/uc/group.uc.ts +++ b/apps/server/src/modules/group/uc/group.uc.ts @@ -14,12 +14,12 @@ import { ConfigService } from '@nestjs/config'; import { SortHelper } from '@shared/common'; import { Page, UserDO } from '@shared/domain/domainobject'; import { SchoolYearEntity, User } from '@shared/domain/entity'; -import { IFindOptions, IGroupFilter, Permission, SortOrder } from '@shared/domain/interface'; +import { IFindOptions, Permission, SortOrder } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { Logger } from '@src/core/logger'; import { LegacySystemService, SystemDto } from '@src/modules/system'; import { ClassRequestContext, SchoolYearQueryType } from '../controller/dto/interface'; -import { Group, GroupTypes, GroupUser } from '../domain'; +import { Group, GroupUser, IGroupFilter } from '../domain'; import { UnknownQueryTypeLoggableException } from '../loggable'; import { GroupService } from '../service'; import { ClassInfoDto, ResolvedGroupDto, ResolvedGroupUser } from './dto'; @@ -41,8 +41,6 @@ export class GroupUc { private readonly logger: Logger ) {} - private ALLOWED_GROUP_TYPES: GroupTypes[] = [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER]; - public async findAllClasses( userId: EntityId, schoolId: EntityId, @@ -72,7 +70,7 @@ export class GroupUc { let combinedClassInfo: ClassInfoDto[]; if (canSeeFullList || calledFromCourse) { - combinedClassInfo = await this.findCombinedClassListForSchool(school, schoolYearQueryType); + combinedClassInfo = await this.findCombinedClassListForSchool(schoolId, schoolYearQueryType); } else { combinedClassInfo = await this.findCombinedClassListForUser(userId, schoolYearQueryType); } @@ -89,15 +87,15 @@ export class GroupUc { } private async findCombinedClassListForSchool( - school: School, + schoolId: EntityId, schoolYearQueryType?: SchoolYearQueryType ): Promise { let classInfosFromGroups: ClassInfoDto[] = []; - const classInfosFromClasses = await this.findClassesForSchool(school.id, schoolYearQueryType); + const classInfosFromClasses = await this.findClassesForSchool(schoolId, schoolYearQueryType); if (!schoolYearQueryType || schoolYearQueryType === SchoolYearQueryType.CURRENT_YEAR) { - classInfosFromGroups = await this.findGroupsForSchool(school); + classInfosFromGroups = await this.findGroupsForSchool(schoolId); } const combinedClassInfo: ClassInfoDto[] = [...classInfosFromClasses, ...classInfosFromGroups]; @@ -225,11 +223,10 @@ export class GroupUc { return classInfosFromClasses; } - private async findGroupsForSchool(school: School): Promise { - const groups: Page = await this.groupService.findGroupsBySchoolIdAndGroupTypes( - school, - this.ALLOWED_GROUP_TYPES - ); + private async findGroupsForSchool(schoolId: EntityId): Promise { + const filter: IGroupFilter = { schoolId }; + + const groups: Page = await this.groupService.findGroups(filter); const classInfosFromGroups: ClassInfoDto[] = await this.getClassInfosFromGroups(groups.data); @@ -237,11 +234,9 @@ export class GroupUc { } private async findGroupsForUser(userId: EntityId): Promise { - const user: UserDO = await this.userService.findById(userId); + const filter: IGroupFilter = { userId }; - const groups: Page = await this.groupService.findGroupsByUserAndGroupTypes(user, this.ALLOWED_GROUP_TYPES, { - pagination: { skip: 0 }, - }); + const groups: Page = await this.groupService.findGroups(filter); const classInfosFromGroups: ClassInfoDto[] = await this.getClassInfosFromGroups(groups.data); @@ -344,6 +339,7 @@ export class GroupUc { return page; } + // ----------------------------------------------------------------------------------------------------------------- public async getGroup(userId: EntityId, groupId: EntityId): Promise { const group: Group = await this.groupService.findById(groupId); @@ -367,8 +363,9 @@ export class GroupUc { public async getAllGroups( userId: EntityId, schoolId: EntityId, - filter: IGroupFilter, - options?: IFindOptions + options: IFindOptions = { pagination: { skip: 0 } }, + nameQuery?: string, + availableGroupsForCourseSync?: boolean ): Promise> { const school: School = await this.schoolService.getSchoolById(schoolId); @@ -377,11 +374,14 @@ export class GroupUc { const canSeeFullList: boolean = this.authorizationService.hasAllPermissions(user, [Permission.GROUP_FULL_ADMIN]); + const filter: IGroupFilter = { nameQuery }; + options.order = { name: SortOrder.asc }; + let groups: Page; if (canSeeFullList) { - groups = await this.getGroupsForSchool(filter, options); + groups = await this.getGroupsForSchool(schoolId, filter, options, availableGroupsForCourseSync); } else { - groups = await this.getGroupsForUser(filter, options); + groups = await this.getGroupsForUser(userId, filter, options, availableGroupsForCourseSync); } const resolvedGroups: ResolvedGroupDto[] = await Promise.all( @@ -398,10 +398,16 @@ export class GroupUc { return page; } - private async getGroupsForSchool(filter: IGroupFilter, options?: IFindOptions): Promise> { - filter.userId = undefined; + private async getGroupsForSchool( + schoolId: EntityId, + filter: IGroupFilter, + options: IFindOptions, + availableGroupsForCourseSync?: boolean + ): Promise> { + filter.schoolId = schoolId; + let foundGroups: Page; - if (filter.availableGroupsForCourseSync && this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { + if (availableGroupsForCourseSync && this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { foundGroups = await this.groupService.findAvailableGroups(filter, options); } else { foundGroups = await this.groupService.findGroups(filter, options); @@ -410,10 +416,16 @@ export class GroupUc { return foundGroups; } - private async getGroupsForUser(filter: IGroupFilter, options?: IFindOptions): Promise> { - filter.schoolId = undefined; + private async getGroupsForUser( + userId: EntityId, + filter: IGroupFilter, + options: IFindOptions, + availableGroupsForCourseSync?: boolean + ): Promise> { + filter.userId = userId; + let foundGroups: Page; - if (filter.availableGroupsForCourseSync && this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { + if (availableGroupsForCourseSync && this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { foundGroups = await this.groupService.findAvailableGroups(filter, options); } else { foundGroups = await this.groupService.findGroups(filter, options); diff --git a/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.ts b/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.ts index 580e38baffe..bd9bbf0b365 100644 --- a/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.ts +++ b/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.ts @@ -1,5 +1,6 @@ -import { Group, GroupService, GroupTypes } from '@modules/group'; +import { Group, GroupService, GroupTypes, IGroupFilter } from '@modules/group'; import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { Page } from '@shared/domain/domainobject'; import { EntityId } from '@shared/domain/types'; import { SchulConneXProvisioningOptions } from '../domain'; import { ProvisioningOptionsUpdateHandler } from './provisioning-options-update-handler'; @@ -30,11 +31,10 @@ export class SchulconnexProvisioningOptionsUpdateService } private async deleteGroups(schoolId: EntityId, systemId: EntityId, groupType: GroupTypes): Promise { - const groups: Group[] = await this.groupService.findGroupsBySchoolIdAndSystemIdAndGroupType( - schoolId, - systemId, - groupType - ); + const filter: IGroupFilter = { schoolId, systemId, groupTypes: [groupType] }; + + const page: Page = await this.groupService.findGroups(filter); + const groups: Group[] = page.data; await Promise.all( groups.map(async (group: Group): Promise => { diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.ts index e84ea0c72d5..af4c0266394 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.ts @@ -1,5 +1,5 @@ import { ObjectId } from '@mikro-orm/mongodb'; -import { Group, GroupService, GroupTypes, GroupUser } from '@modules/group'; +import { Group, GroupService, GroupTypes, GroupUser, IGroupFilter } from '@modules/group'; import { LegacySchoolService, SchoolSystemOptionsService, @@ -175,7 +175,8 @@ export class SchulconnexGroupProvisioningService { throw new NotFoundLoggableException(UserDO.name, { externalId: externalUserId }); } - const existingGroupsOfUser: Page = await this.groupService.findGroupsByUserAndGroupTypes(user); + const filter: IGroupFilter = { userId: user.id }; + const existingGroupsOfUser: Page = await this.groupService.findGroups(filter); const groupsFromSystem: Group[] = existingGroupsOfUser.data.filter( (existingGroup: Group) => existingGroup.externalSource?.systemId === systemId diff --git a/apps/server/src/shared/domain/interface/find-options.ts b/apps/server/src/shared/domain/interface/find-options.ts index 82ee2398041..e1c9e005e8b 100644 --- a/apps/server/src/shared/domain/interface/find-options.ts +++ b/apps/server/src/shared/domain/interface/find-options.ts @@ -1,6 +1,3 @@ -import { GroupTypes } from '@modules/group'; -import { EntityId } from '@shared/domain/types'; - export interface Pagination { skip?: number; limit?: number; @@ -18,19 +15,4 @@ export interface IFindOptions { order?: SortOrderMap; } -// TODO N21-1860 delete -export interface IFindQuery { - pagination?: Pagination; - nameQuery?: string; -} - -export interface IGroupFilter { - userId?: EntityId; - schoolId?: EntityId; - groupTypes?: GroupTypes[]; - nameQuery?: string; - availableGroupsForCourseSync?: boolean; - systemId?: EntityId; -} - export type SortOrderNumberType = Partial>; From d26da4af5fc70cf05b1c09b551659a88a5dbc6c6 Mon Sep 17 00:00:00 2001 From: Igor Richter Date: Mon, 6 May 2024 12:58:36 +0200 Subject: [PATCH 03/11] - service unit tests - ToDo: uc unit tests, splitting groupUc --- .../group/service/group.service.spec.ts | 255 +++++++----------- 1 file changed, 97 insertions(+), 158 deletions(-) diff --git a/apps/server/src/modules/group/service/group.service.spec.ts b/apps/server/src/modules/group/service/group.service.spec.ts index 0ee2c08ba00..ca115317e9c 100644 --- a/apps/server/src/modules/group/service/group.service.spec.ts +++ b/apps/server/src/modules/group/service/group.service.spec.ts @@ -1,12 +1,11 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { EventBus } from '@nestjs/cqrs'; -import { School } from '@modules/school'; -import { schoolFactory } from '@modules/school/testing'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { Page, UserDO } from '@shared/domain/domainobject'; -import { groupFactory, userDoFactory } from '@shared/testing'; +import { Page } from '@shared/domain/domainobject'; +import { EntityId } from '@shared/domain/types'; +import { groupFactory } from '@shared/testing'; import { Group, GroupDeletedEvent, GroupTypes } from '../domain'; import { GroupRepo } from '../repo'; import { GroupService } from './group.service'; @@ -130,246 +129,186 @@ describe('GroupService', () => { }); }); - describe('findGroupsByUserAndGroupTypes', () => { - describe('when groups with the user exists', () => { + describe('findGroups', () => { + describe('when groups exist', () => { const setup = () => { - const user: UserDO = userDoFactory.buildWithId(); + const userId: EntityId = new ObjectId().toHexString(); + const schoolId: EntityId = new ObjectId().toHexString(); + const systemId: EntityId = new ObjectId().toHexString(); + const nameQuery = 'name'; const groups: Group[] = groupFactory.buildList(2); const page: Page = new Page(groups, groups.length); - groupRepo.findByUserAndGroupTypes.mockResolvedValue(page); + groupRepo.findGroups.mockResolvedValue(page); return { - user, + userId, + schoolId, + systemId, + nameQuery, groups, }; }; - it('should return the groups', async () => { - const { user, groups } = setup(); + it('should return the groups for the user', async () => { + const { userId, groups } = setup(); - const result: Page = await service.findGroupsByUserAndGroupTypes(user, [GroupTypes.CLASS]); + const result: Page = await service.findGroups({ userId }); expect(result.data).toEqual(groups); }); - it('should call the repo with given group types', async () => { - const { user } = setup(); + it('should return the groups for school', async () => { + const { schoolId, groups } = setup(); - await service.findGroupsByUserAndGroupTypes(user, [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER]); + const result: Page = await service.findGroups({ schoolId }); - expect(groupRepo.findByUserAndGroupTypes).toHaveBeenCalledWith( - user, - [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER], - undefined - ); - }); - }); - - describe('when no groups with the user exists', () => { - const setup = () => { - const user: UserDO = userDoFactory.buildWithId(); - - groupRepo.findByUserAndGroupTypes.mockResolvedValue(new Page([], 0)); - - return { - user, - }; - }; - - it('should return empty array', async () => { - const { user } = setup(); - - const result: Page = await service.findGroupsByUserAndGroupTypes(user, [GroupTypes.CLASS]); - - expect(result.data).toEqual([]); + expect(result.data).toEqual(groups); }); - }); - }); - - describe('findAvailableGroupByUser', () => { - describe('when available groups exist for user', () => { - const setup = () => { - const user: UserDO = userDoFactory.buildWithId(); - const groups: Group[] = groupFactory.buildList(2); - - groupRepo.findAvailableByUser.mockResolvedValue(new Page([groups[1]], 1)); - return { - user, - groups, - }; - }; - - it('should call repo', async () => { - const { user } = setup(); + it('should return the groups for school and system', async () => { + const { schoolId, systemId, groups } = setup(); - await service.findAvailableGroupsByUser(user); + const result: Page = await service.findGroups({ schoolId, systemId }); - expect(groupRepo.findAvailableByUser).toHaveBeenCalledWith(user, undefined); + expect(result.data).toEqual(groups); }); - it('should return groups', async () => { - const { user, groups } = setup(); + it('should call the repo with all given arguments', async () => { + const { userId, schoolId, systemId, nameQuery } = setup(); - const result: Page = await service.findAvailableGroupsByUser(user); - - expect(result.data).toEqual([groups[1]]); + await service.findGroups({ + userId, + schoolId, + systemId, + nameQuery, + groupTypes: [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER], + }); + + expect(groupRepo.findGroups).toHaveBeenCalledWith( + { + userId, + schoolId, + systemId, + nameQuery, + groupTypes: [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER], + }, + undefined + ); }); }); - describe('when no groups with the user exists', () => { + describe('when no groups exist', () => { const setup = () => { - const user: UserDO = userDoFactory.buildWithId(); + const userId: EntityId = new ObjectId().toHexString(); + const schoolId: EntityId = new ObjectId().toHexString(); + const systemId: EntityId = new ObjectId().toHexString(); - groupRepo.findAvailableByUser.mockResolvedValue(new Page([], 0)); + groupRepo.findGroups.mockResolvedValue(new Page([], 0)); return { - user, + userId, + schoolId, + systemId, }; }; - it('should return empty array', async () => { - const { user } = setup(); + it('should return empty array for user', async () => { + const { userId } = setup(); - const result: Page = await service.findAvailableGroupsByUser(user); + const result: Page = await service.findGroups({ userId }); expect(result.data).toEqual([]); }); - }); - }); - describe('findGroupsBySchoolIdAndGroupTypes', () => { - describe('when the school has groups of type class', () => { - const setup = () => { - const school: School = schoolFactory.build(); - const groups: Group[] = groupFactory.buildList(3); - const page: Page = new Page(groups, groups.length); + it('should return empty array for school', async () => { + const { schoolId } = setup(); - groupRepo.findBySchoolIdAndGroupTypes.mockResolvedValue(page); + const result: Page = await service.findGroups({ schoolId }); - return { - school, - groups, - }; - }; - - it('should call the repo', async () => { - const { school } = setup(); - - await service.findGroupsBySchoolIdAndGroupTypes(school, [ - GroupTypes.CLASS, - GroupTypes.COURSE, - GroupTypes.OTHER, - ]); - - expect(groupRepo.findBySchoolIdAndGroupTypes).toHaveBeenCalledWith( - school, - [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER], - undefined - ); + expect(result.data).toEqual([]); }); - it('should return the groups', async () => { - const { school, groups } = setup(); + it('should return empty array for school and system', async () => { + const { schoolId, systemId } = setup(); - const result: Page = await service.findGroupsBySchoolIdAndGroupTypes(school, [GroupTypes.CLASS]); + const result: Page = await service.findGroups({ schoolId, systemId }); - expect(result.data).toEqual(groups); + expect(result.data).toEqual([]); }); }); }); - describe('findAvailableGroupBySchoolId', () => { - describe('when available groups exist for school', () => { + describe('findAvailableGroups', () => { + describe('when available groups exist', () => { const setup = () => { - const school: School = schoolFactory.build(); + const userId: EntityId = new ObjectId().toHexString(); + const schoolId: EntityId = new ObjectId().toHexString(); + const nameQuery = 'name'; const groups: Group[] = groupFactory.buildList(2); - groupRepo.findAvailableBySchoolId.mockResolvedValue(new Page([groups[1]], 1)); + groupRepo.findAvailableGroups.mockResolvedValue(new Page([groups[1]], 1)); return { - school, + userId, + schoolId, + nameQuery, groups, }; }; - it('should call repo', async () => { - const { school } = setup(); + it('should return groups for user', async () => { + const { userId, groups } = setup(); - await service.findAvailableGroupsBySchoolId(school); + const result: Page = await service.findAvailableGroups({ userId }); - expect(groupRepo.findAvailableBySchoolId).toHaveBeenCalledWith(school, undefined); + expect(result.data).toEqual([groups[1]]); }); - it('should return groups', async () => { - const { school, groups } = setup(); + it('should return groups for school', async () => { + const { schoolId, groups } = setup(); - const result: Page = await service.findAvailableGroupsBySchoolId(school); + const result: Page = await service.findAvailableGroups({ schoolId }); expect(result.data).toEqual([groups[1]]); }); - }); - - describe('when no groups with the user exists', () => { - const setup = () => { - const school: School = schoolFactory.build(); - groupRepo.findAvailableBySchoolId.mockResolvedValue(new Page([], 0)); - - return { - school, - }; - }; - - it('should return empty array', async () => { - const { school } = setup(); + it('should call repo', async () => { + const { userId, schoolId, nameQuery } = setup(); - const result: Page = await service.findAvailableGroupsBySchoolId(school); + await service.findAvailableGroups({ userId, schoolId, nameQuery }); - expect(result.data).toEqual([]); + expect(groupRepo.findAvailableGroups).toHaveBeenCalledWith({ userId, schoolId, nameQuery }, undefined); }); }); - }); - describe('findGroupsBySchoolIdAndSystemIdAndGroupType', () => { - describe('when the school has groups of type class', () => { + describe('when no groups exist', () => { const setup = () => { - const schoolId: string = new ObjectId().toHexString(); - const systemId: string = new ObjectId().toHexString(); - const groups: Group[] = groupFactory.buildList(3); + const userId: EntityId = new ObjectId().toHexString(); + const schoolId: EntityId = new ObjectId().toHexString(); - groupRepo.findGroupsBySchoolIdAndSystemIdAndGroupType.mockResolvedValue(groups); + groupRepo.findAvailableGroups.mockResolvedValue(new Page([], 0)); return { + userId, schoolId, - systemId, - groups, }; }; - it('should search for the groups', async () => { - const { schoolId, systemId } = setup(); + it('should return empty array for user', async () => { + const { userId } = setup(); - await service.findGroupsBySchoolIdAndSystemIdAndGroupType(schoolId, systemId, GroupTypes.CLASS); + const result: Page = await service.findAvailableGroups({ userId }); - expect(groupRepo.findGroupsBySchoolIdAndSystemIdAndGroupType).toHaveBeenCalledWith( - schoolId, - systemId, - GroupTypes.CLASS - ); + expect(result.data).toEqual([]); }); - it('should return the groups', async () => { - const { schoolId, systemId, groups } = setup(); + it('should return empty array for school', async () => { + const { schoolId } = setup(); - const result: Group[] = await service.findGroupsBySchoolIdAndSystemIdAndGroupType( - schoolId, - systemId, - GroupTypes.CLASS - ); + const result: Page = await service.findAvailableGroups({ schoolId }); - expect(result).toEqual(groups); + expect(result.data).toEqual([]); }); }); }); From 7d64f11ccb908632e5a93e7e5e7f07a9577dbe68 Mon Sep 17 00:00:00 2001 From: Igor Richter Date: Mon, 6 May 2024 12:58:36 +0200 Subject: [PATCH 04/11] - service unit tests - ToDo: uc unit tests, splitting groupUc --- .../group/service/group.service.spec.ts | 255 +++++++----------- ...rovisioning-options-update.service.spec.ts | 40 +-- ...lconnex-group-provisioning.service.spec.ts | 8 +- 3 files changed, 123 insertions(+), 180 deletions(-) diff --git a/apps/server/src/modules/group/service/group.service.spec.ts b/apps/server/src/modules/group/service/group.service.spec.ts index 0ee2c08ba00..ca115317e9c 100644 --- a/apps/server/src/modules/group/service/group.service.spec.ts +++ b/apps/server/src/modules/group/service/group.service.spec.ts @@ -1,12 +1,11 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { EventBus } from '@nestjs/cqrs'; -import { School } from '@modules/school'; -import { schoolFactory } from '@modules/school/testing'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { Page, UserDO } from '@shared/domain/domainobject'; -import { groupFactory, userDoFactory } from '@shared/testing'; +import { Page } from '@shared/domain/domainobject'; +import { EntityId } from '@shared/domain/types'; +import { groupFactory } from '@shared/testing'; import { Group, GroupDeletedEvent, GroupTypes } from '../domain'; import { GroupRepo } from '../repo'; import { GroupService } from './group.service'; @@ -130,246 +129,186 @@ describe('GroupService', () => { }); }); - describe('findGroupsByUserAndGroupTypes', () => { - describe('when groups with the user exists', () => { + describe('findGroups', () => { + describe('when groups exist', () => { const setup = () => { - const user: UserDO = userDoFactory.buildWithId(); + const userId: EntityId = new ObjectId().toHexString(); + const schoolId: EntityId = new ObjectId().toHexString(); + const systemId: EntityId = new ObjectId().toHexString(); + const nameQuery = 'name'; const groups: Group[] = groupFactory.buildList(2); const page: Page = new Page(groups, groups.length); - groupRepo.findByUserAndGroupTypes.mockResolvedValue(page); + groupRepo.findGroups.mockResolvedValue(page); return { - user, + userId, + schoolId, + systemId, + nameQuery, groups, }; }; - it('should return the groups', async () => { - const { user, groups } = setup(); + it('should return the groups for the user', async () => { + const { userId, groups } = setup(); - const result: Page = await service.findGroupsByUserAndGroupTypes(user, [GroupTypes.CLASS]); + const result: Page = await service.findGroups({ userId }); expect(result.data).toEqual(groups); }); - it('should call the repo with given group types', async () => { - const { user } = setup(); + it('should return the groups for school', async () => { + const { schoolId, groups } = setup(); - await service.findGroupsByUserAndGroupTypes(user, [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER]); + const result: Page = await service.findGroups({ schoolId }); - expect(groupRepo.findByUserAndGroupTypes).toHaveBeenCalledWith( - user, - [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER], - undefined - ); - }); - }); - - describe('when no groups with the user exists', () => { - const setup = () => { - const user: UserDO = userDoFactory.buildWithId(); - - groupRepo.findByUserAndGroupTypes.mockResolvedValue(new Page([], 0)); - - return { - user, - }; - }; - - it('should return empty array', async () => { - const { user } = setup(); - - const result: Page = await service.findGroupsByUserAndGroupTypes(user, [GroupTypes.CLASS]); - - expect(result.data).toEqual([]); + expect(result.data).toEqual(groups); }); - }); - }); - - describe('findAvailableGroupByUser', () => { - describe('when available groups exist for user', () => { - const setup = () => { - const user: UserDO = userDoFactory.buildWithId(); - const groups: Group[] = groupFactory.buildList(2); - - groupRepo.findAvailableByUser.mockResolvedValue(new Page([groups[1]], 1)); - return { - user, - groups, - }; - }; - - it('should call repo', async () => { - const { user } = setup(); + it('should return the groups for school and system', async () => { + const { schoolId, systemId, groups } = setup(); - await service.findAvailableGroupsByUser(user); + const result: Page = await service.findGroups({ schoolId, systemId }); - expect(groupRepo.findAvailableByUser).toHaveBeenCalledWith(user, undefined); + expect(result.data).toEqual(groups); }); - it('should return groups', async () => { - const { user, groups } = setup(); + it('should call the repo with all given arguments', async () => { + const { userId, schoolId, systemId, nameQuery } = setup(); - const result: Page = await service.findAvailableGroupsByUser(user); - - expect(result.data).toEqual([groups[1]]); + await service.findGroups({ + userId, + schoolId, + systemId, + nameQuery, + groupTypes: [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER], + }); + + expect(groupRepo.findGroups).toHaveBeenCalledWith( + { + userId, + schoolId, + systemId, + nameQuery, + groupTypes: [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER], + }, + undefined + ); }); }); - describe('when no groups with the user exists', () => { + describe('when no groups exist', () => { const setup = () => { - const user: UserDO = userDoFactory.buildWithId(); + const userId: EntityId = new ObjectId().toHexString(); + const schoolId: EntityId = new ObjectId().toHexString(); + const systemId: EntityId = new ObjectId().toHexString(); - groupRepo.findAvailableByUser.mockResolvedValue(new Page([], 0)); + groupRepo.findGroups.mockResolvedValue(new Page([], 0)); return { - user, + userId, + schoolId, + systemId, }; }; - it('should return empty array', async () => { - const { user } = setup(); + it('should return empty array for user', async () => { + const { userId } = setup(); - const result: Page = await service.findAvailableGroupsByUser(user); + const result: Page = await service.findGroups({ userId }); expect(result.data).toEqual([]); }); - }); - }); - describe('findGroupsBySchoolIdAndGroupTypes', () => { - describe('when the school has groups of type class', () => { - const setup = () => { - const school: School = schoolFactory.build(); - const groups: Group[] = groupFactory.buildList(3); - const page: Page = new Page(groups, groups.length); + it('should return empty array for school', async () => { + const { schoolId } = setup(); - groupRepo.findBySchoolIdAndGroupTypes.mockResolvedValue(page); + const result: Page = await service.findGroups({ schoolId }); - return { - school, - groups, - }; - }; - - it('should call the repo', async () => { - const { school } = setup(); - - await service.findGroupsBySchoolIdAndGroupTypes(school, [ - GroupTypes.CLASS, - GroupTypes.COURSE, - GroupTypes.OTHER, - ]); - - expect(groupRepo.findBySchoolIdAndGroupTypes).toHaveBeenCalledWith( - school, - [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER], - undefined - ); + expect(result.data).toEqual([]); }); - it('should return the groups', async () => { - const { school, groups } = setup(); + it('should return empty array for school and system', async () => { + const { schoolId, systemId } = setup(); - const result: Page = await service.findGroupsBySchoolIdAndGroupTypes(school, [GroupTypes.CLASS]); + const result: Page = await service.findGroups({ schoolId, systemId }); - expect(result.data).toEqual(groups); + expect(result.data).toEqual([]); }); }); }); - describe('findAvailableGroupBySchoolId', () => { - describe('when available groups exist for school', () => { + describe('findAvailableGroups', () => { + describe('when available groups exist', () => { const setup = () => { - const school: School = schoolFactory.build(); + const userId: EntityId = new ObjectId().toHexString(); + const schoolId: EntityId = new ObjectId().toHexString(); + const nameQuery = 'name'; const groups: Group[] = groupFactory.buildList(2); - groupRepo.findAvailableBySchoolId.mockResolvedValue(new Page([groups[1]], 1)); + groupRepo.findAvailableGroups.mockResolvedValue(new Page([groups[1]], 1)); return { - school, + userId, + schoolId, + nameQuery, groups, }; }; - it('should call repo', async () => { - const { school } = setup(); + it('should return groups for user', async () => { + const { userId, groups } = setup(); - await service.findAvailableGroupsBySchoolId(school); + const result: Page = await service.findAvailableGroups({ userId }); - expect(groupRepo.findAvailableBySchoolId).toHaveBeenCalledWith(school, undefined); + expect(result.data).toEqual([groups[1]]); }); - it('should return groups', async () => { - const { school, groups } = setup(); + it('should return groups for school', async () => { + const { schoolId, groups } = setup(); - const result: Page = await service.findAvailableGroupsBySchoolId(school); + const result: Page = await service.findAvailableGroups({ schoolId }); expect(result.data).toEqual([groups[1]]); }); - }); - - describe('when no groups with the user exists', () => { - const setup = () => { - const school: School = schoolFactory.build(); - groupRepo.findAvailableBySchoolId.mockResolvedValue(new Page([], 0)); - - return { - school, - }; - }; - - it('should return empty array', async () => { - const { school } = setup(); + it('should call repo', async () => { + const { userId, schoolId, nameQuery } = setup(); - const result: Page = await service.findAvailableGroupsBySchoolId(school); + await service.findAvailableGroups({ userId, schoolId, nameQuery }); - expect(result.data).toEqual([]); + expect(groupRepo.findAvailableGroups).toHaveBeenCalledWith({ userId, schoolId, nameQuery }, undefined); }); }); - }); - describe('findGroupsBySchoolIdAndSystemIdAndGroupType', () => { - describe('when the school has groups of type class', () => { + describe('when no groups exist', () => { const setup = () => { - const schoolId: string = new ObjectId().toHexString(); - const systemId: string = new ObjectId().toHexString(); - const groups: Group[] = groupFactory.buildList(3); + const userId: EntityId = new ObjectId().toHexString(); + const schoolId: EntityId = new ObjectId().toHexString(); - groupRepo.findGroupsBySchoolIdAndSystemIdAndGroupType.mockResolvedValue(groups); + groupRepo.findAvailableGroups.mockResolvedValue(new Page([], 0)); return { + userId, schoolId, - systemId, - groups, }; }; - it('should search for the groups', async () => { - const { schoolId, systemId } = setup(); + it('should return empty array for user', async () => { + const { userId } = setup(); - await service.findGroupsBySchoolIdAndSystemIdAndGroupType(schoolId, systemId, GroupTypes.CLASS); + const result: Page = await service.findAvailableGroups({ userId }); - expect(groupRepo.findGroupsBySchoolIdAndSystemIdAndGroupType).toHaveBeenCalledWith( - schoolId, - systemId, - GroupTypes.CLASS - ); + expect(result.data).toEqual([]); }); - it('should return the groups', async () => { - const { schoolId, systemId, groups } = setup(); + it('should return empty array for school', async () => { + const { schoolId } = setup(); - const result: Group[] = await service.findGroupsBySchoolIdAndSystemIdAndGroupType( - schoolId, - systemId, - GroupTypes.CLASS - ); + const result: Page = await service.findAvailableGroups({ schoolId }); - expect(result).toEqual(groups); + expect(result.data).toEqual([]); }); }); }); diff --git a/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.spec.ts b/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.spec.ts index 57f2c9387a4..746c27c5a3e 100644 --- a/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.spec.ts +++ b/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.spec.ts @@ -1,6 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Group, GroupService, GroupTypes } from '@modules/group'; import { Test, TestingModule } from '@nestjs/testing'; +import { Page } from '@shared/domain/domainobject'; import { groupFactory, schoolSystemOptionsFactory } from '@shared/testing'; import { SchoolSystemOptions, SchulConneXProvisioningOptions } from '../domain'; import { SchulconnexProvisioningOptionsUpdateService } from './schulconnex-provisioning-options-update.service'; @@ -50,8 +51,9 @@ describe(SchulconnexProvisioningOptionsUpdateService.name, () => { groupProvisioningCoursesEnabled: true, }); const group: Group = groupFactory.build({ type: GroupTypes.CLASS }); + const page: Page = new Page([group], 1); - groupService.findGroupsBySchoolIdAndSystemIdAndGroupType.mockResolvedValueOnce([group]); + groupService.findGroups.mockResolvedValueOnce(page); return { schoolSystemOptions, @@ -70,11 +72,11 @@ describe(SchulconnexProvisioningOptionsUpdateService.name, () => { schoolSystemOptions.provisioningOptions ); - expect(groupService.findGroupsBySchoolIdAndSystemIdAndGroupType).toHaveBeenCalledWith( - schoolSystemOptions.schoolId, - schoolSystemOptions.systemId, - GroupTypes.CLASS - ); + expect(groupService.findGroups).toHaveBeenCalledWith({ + schoolId: schoolSystemOptions.schoolId, + systemId: schoolSystemOptions.systemId, + groupTypes: [GroupTypes.CLASS], + }); }); it('should delete all classes', async () => { @@ -107,8 +109,9 @@ describe(SchulconnexProvisioningOptionsUpdateService.name, () => { groupProvisioningCoursesEnabled: false, }); const group: Group = groupFactory.build({ type: GroupTypes.COURSE }); + const page: Page = new Page([group], 1); - groupService.findGroupsBySchoolIdAndSystemIdAndGroupType.mockResolvedValueOnce([group]); + groupService.findGroups.mockResolvedValueOnce(page); return { schoolSystemOptions, @@ -127,11 +130,11 @@ describe(SchulconnexProvisioningOptionsUpdateService.name, () => { schoolSystemOptions.provisioningOptions ); - expect(groupService.findGroupsBySchoolIdAndSystemIdAndGroupType).toHaveBeenCalledWith( - schoolSystemOptions.schoolId, - schoolSystemOptions.systemId, - GroupTypes.COURSE - ); + expect(groupService.findGroups).toHaveBeenCalledWith({ + schoolId: schoolSystemOptions.schoolId, + systemId: schoolSystemOptions.systemId, + groupTypes: [GroupTypes.COURSE], + }); }); it('should delete all courses', async () => { @@ -164,8 +167,9 @@ describe(SchulconnexProvisioningOptionsUpdateService.name, () => { groupProvisioningCoursesEnabled: true, }); const group: Group = groupFactory.build({ type: GroupTypes.OTHER }); + const page: Page = new Page([group], 1); - groupService.findGroupsBySchoolIdAndSystemIdAndGroupType.mockResolvedValueOnce([group]); + groupService.findGroups.mockResolvedValueOnce(page); return { schoolSystemOptions, @@ -184,11 +188,11 @@ describe(SchulconnexProvisioningOptionsUpdateService.name, () => { schoolSystemOptions.provisioningOptions ); - expect(groupService.findGroupsBySchoolIdAndSystemIdAndGroupType).toHaveBeenCalledWith( - schoolSystemOptions.schoolId, - schoolSystemOptions.systemId, - GroupTypes.OTHER - ); + expect(groupService.findGroups).toHaveBeenCalledWith({ + schoolId: schoolSystemOptions.schoolId, + systemId: schoolSystemOptions.systemId, + groupTypes: [GroupTypes.OTHER], + }); }); it('should delete all other groups', async () => { diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.spec.ts index a027c40ef36..39deea9992b 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.spec.ts @@ -6,7 +6,7 @@ import { SchoolSystemOptionsService, SchulConneXProvisioningOptions, } from '@modules/legacy-school'; -import { RoleService, RoleDto } from '@modules/role'; +import { RoleDto, RoleService } from '@modules/role'; import { UserService } from '@modules/user'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; @@ -652,7 +652,7 @@ describe(SchulconnexGroupProvisioningService.name, () => { const externalGroups: ExternalGroupDto[] = [firstExternalGroup, secondExternalGroup]; userService.findByExternalId.mockResolvedValue(user); - groupService.findGroupsByUserAndGroupTypes.mockResolvedValue(new Page(existingGroups, 2)); + groupService.findGroups.mockResolvedValue(new Page(existingGroups, 2)); return { externalGroups, @@ -709,7 +709,7 @@ describe(SchulconnexGroupProvisioningService.name, () => { const externalGroups: ExternalGroupDto[] = [firstExternalGroup]; userService.findByExternalId.mockResolvedValue(user); - groupService.findGroupsByUserAndGroupTypes.mockResolvedValue(new Page(existingGroups, 2)); + groupService.findGroups.mockResolvedValue(new Page(existingGroups, 2)); return { externalGroups, @@ -788,7 +788,7 @@ describe(SchulconnexGroupProvisioningService.name, () => { const externalGroups: ExternalGroupDto[] = [firstExternalGroup]; userService.findByExternalId.mockResolvedValueOnce(user); - groupService.findGroupsByUserAndGroupTypes.mockResolvedValueOnce(new Page(existingGroups, 2)); + groupService.findGroups.mockResolvedValueOnce(new Page(existingGroups, 2)); groupService.save.mockResolvedValueOnce(secondExistingGroup); return { From f4f2c1dd07f33c6d66746afd2b0e04a29e185122 Mon Sep 17 00:00:00 2001 From: Igor Richter Date: Wed, 8 May 2024 10:44:36 +0200 Subject: [PATCH 05/11] - uc unit tests - ToDo: splitting groupUc --- .../src/modules/group/uc/group.uc.spec.ts | 50 +++++++------------ 1 file changed, 17 insertions(+), 33 deletions(-) diff --git a/apps/server/src/modules/group/uc/group.uc.spec.ts b/apps/server/src/modules/group/uc/group.uc.spec.ts index 6c4285122eb..51da9de9147 100644 --- a/apps/server/src/modules/group/uc/group.uc.spec.ts +++ b/apps/server/src/modules/group/uc/group.uc.spec.ts @@ -21,7 +21,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { Page, UserDO } from '@shared/domain/domainobject'; import { Role, SchoolYearEntity, User } from '@shared/domain/entity'; -import { IFindQuery, Permission, SortOrder } from '@shared/domain/interface'; +import { Permission, SortOrder } from '@shared/domain/interface'; import { groupFactory, roleDtoFactory, @@ -34,7 +34,7 @@ import { } from '@shared/testing'; import { Logger } from '@src/core/logger'; import { ClassRequestContext, SchoolYearQueryType } from '../controller/dto/interface'; -import { Group, GroupTypes } from '../domain'; +import { Group, GroupTypes, IGroupFilter } from '../domain'; import { UnknownQueryTypeLoggableException } from '../loggable'; import { GroupService } from '../service'; import { ClassInfoDto, ResolvedGroupDto } from './dto'; @@ -230,11 +230,9 @@ describe('GroupUc', () => { authorizationService.getUserWithPermissions.mockResolvedValueOnce(teacherUser); authorizationService.hasAllPermissions.mockReturnValueOnce(false); classService.findAllByUserId.mockResolvedValueOnce([clazz, successorClass, classWithoutSchoolYear]); - groupService.findGroupsByUserAndGroupTypes.mockResolvedValueOnce(new Page([group, groupWithSystem], 2)); + groupService.findGroups.mockResolvedValueOnce(new Page([group, groupWithSystem], 2)); classService.findClassesForSchool.mockResolvedValueOnce([clazz, successorClass, classWithoutSchoolYear]); - groupService.findGroupsBySchoolIdAndGroupTypes.mockResolvedValueOnce( - new Page([group, groupWithSystem], 2) - ); + groupService.findGroups.mockResolvedValueOnce(new Page([group, groupWithSystem], 2)); systemService.findById.mockResolvedValue(system); userService.findById.mockImplementation((userId: string): Promise => { if (userId === teacherUser.id) { @@ -411,16 +409,12 @@ describe('GroupUc', () => { }); }); - it('should call group service with allowed group types', async () => { + it('should call group service with userId and no pagination', async () => { const { teacherUser } = setup(); await uc.findAllClasses(teacherUser.id, teacherUser.school.id); - expect(groupService.findGroupsByUserAndGroupTypes).toHaveBeenCalledWith<[UserDO, GroupTypes[], IFindQuery]>( - expect.any(UserDO), - [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER], - { pagination: { skip: 0 } } - ); + expect(groupService.findGroups).toHaveBeenCalledWith<[IGroupFilter]>({ userId: teacherUser.id }); }); }); @@ -652,9 +646,7 @@ describe('GroupUc', () => { authorizationService.getUserWithPermissions.mockResolvedValueOnce(adminUser); authorizationService.hasAllPermissions.mockReturnValueOnce(true); classService.findClassesForSchool.mockResolvedValueOnce([...clazzes, clazz]); - groupService.findGroupsBySchoolIdAndGroupTypes.mockResolvedValueOnce( - new Page([group, groupWithSystem], 2) - ); + groupService.findGroups.mockResolvedValueOnce(new Page([group, groupWithSystem], 2)); systemService.findById.mockResolvedValue(system); userService.findById.mockImplementation((userId: string): Promise => { @@ -781,16 +773,12 @@ describe('GroupUc', () => { }); }); - it('should call group service with allowed group types', async () => { - const { teacherUser, school } = setup(); + it('should call group service with schoolId and no pagination', async () => { + const { teacherUser } = setup(); await uc.findAllClasses(teacherUser.id, teacherUser.school.id); - expect(groupService.findGroupsBySchoolIdAndGroupTypes).toHaveBeenCalledWith<[School, GroupTypes[]]>(school, [ - GroupTypes.CLASS, - GroupTypes.COURSE, - GroupTypes.OTHER, - ]); + expect(groupService.findGroups).toHaveBeenCalledWith<[IGroupFilter]>({ schoolId: teacherUser.school.id }); }); }); @@ -948,7 +936,7 @@ describe('GroupUc', () => { authorizationService.getUserWithPermissions.mockResolvedValueOnce(teacherUser); authorizationService.hasAllPermissions.mockReturnValueOnce(false); classService.findAllByUserId.mockResolvedValueOnce([clazz]); - groupService.findGroupsByUserAndGroupTypes.mockResolvedValueOnce(new Page([group], 1)); + groupService.findGroups.mockResolvedValueOnce(new Page([group], 1)); systemService.findById.mockResolvedValue(system); userService.findById.mockImplementation((userId: string): Promise => { @@ -1217,10 +1205,8 @@ describe('GroupUc', () => { schoolService.getSchoolById.mockResolvedValue(school); authorizationService.getUserWithPermissions.mockResolvedValue(user); authorizationService.hasAllPermissions.mockReturnValueOnce(true); - groupService.findAvailableGroupsBySchoolId.mockResolvedValue(new Page([availableGroupInSchool], 1)); - groupService.findGroupsBySchoolIdAndGroupTypes.mockResolvedValue( - new Page([groupInSchool, availableGroupInSchool], 2) - ); + groupService.findAvailableGroups.mockResolvedValue(new Page([availableGroupInSchool], 1)); + groupService.findGroups.mockResolvedValue(new Page([groupInSchool, availableGroupInSchool], 2)); userService.findByIdOrNull.mockResolvedValue(userDto); roleService.findById.mockResolvedValue(userRole); @@ -1328,7 +1314,7 @@ describe('GroupUc', () => { it('should return all available groups for course sync', async () => { const { user, availableGroupInSchool, school } = setup(); - const response = await uc.getAllGroups(user.id, school.id, undefined, true); + const response = await uc.getAllGroups(user.id, school.id, undefined, undefined, true); expect(response).toMatchObject({ data: [ @@ -1391,10 +1377,8 @@ describe('GroupUc', () => { schoolService.getSchoolById.mockResolvedValue(school); authorizationService.getUserWithPermissions.mockResolvedValue(user); authorizationService.hasAllPermissions.mockReturnValue(false); - groupService.findAvailableGroupsByUser.mockResolvedValue(new Page([availableTeachersGroup], 1)); - groupService.findGroupsByUserAndGroupTypes.mockResolvedValue( - new Page([teachersGroup, availableTeachersGroup], 2) - ); + groupService.findAvailableGroups.mockResolvedValue(new Page([availableTeachersGroup], 1)); + groupService.findGroups.mockResolvedValue(new Page([teachersGroup, availableTeachersGroup], 2)); userService.findByIdOrNull.mockResolvedValue(userDto); roleService.findById.mockResolvedValue(userRole); @@ -1502,7 +1486,7 @@ describe('GroupUc', () => { it('should return all available groups for course sync the teacher is part of', async () => { const { user, availableTeachersGroup, school } = setup(); - const response = await uc.getAllGroups(user.id, school.id, undefined, true); + const response = await uc.getAllGroups(user.id, school.id, undefined, undefined, true); expect(response).toMatchObject({ data: [ From 558caf4f53a11f3fe118ee83743222a2db2941fe Mon Sep 17 00:00:00 2001 From: Igor Richter Date: Wed, 8 May 2024 11:56:04 +0200 Subject: [PATCH 06/11] - splitting groupUc --- .../modules/group/uc/class-group.uc.spec.ts | 1002 +++++++++++++++++ .../src/modules/group/uc/class-group.uc.ts | 303 +++++ .../src/modules/group/uc/group.uc.spec.ts | 923 +-------------- apps/server/src/modules/group/uc/group.uc.ts | 310 +---- apps/server/src/modules/group/uc/index.ts | 3 +- 5 files changed, 1326 insertions(+), 1215 deletions(-) create mode 100644 apps/server/src/modules/group/uc/class-group.uc.spec.ts create mode 100644 apps/server/src/modules/group/uc/class-group.uc.ts diff --git a/apps/server/src/modules/group/uc/class-group.uc.spec.ts b/apps/server/src/modules/group/uc/class-group.uc.spec.ts new file mode 100644 index 00000000000..7fc75d46042 --- /dev/null +++ b/apps/server/src/modules/group/uc/class-group.uc.spec.ts @@ -0,0 +1,1002 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Action, AuthorizationContext, AuthorizationService } from '@modules/authorization'; +import { ClassService } from '@modules/class'; +import { Class } from '@modules/class/domain'; +import { classFactory } from '@modules/class/domain/testing/factory/class.factory'; +import { ClassGroupUc } from '@modules/group/uc/class-group.uc'; +import { Course } from '@modules/learnroom/domain'; +import { CourseDoService } from '@modules/learnroom/service/course-do.service'; +import { courseFactory } from '@modules/learnroom/testing'; +import { SchoolYearService } from '@modules/legacy-school'; +import { ProvisioningConfig } from '@modules/provisioning'; +import { RoleService } from '@modules/role'; +import { RoleDto } from '@modules/role/service/dto/role.dto'; +import { School, SchoolService } from '@modules/school/domain'; +import { schoolFactory } from '@modules/school/testing'; +import { LegacySystemService, SystemDto } from '@modules/system'; +import { UserService } from '@modules/user'; +import { ForbiddenException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Page, UserDO } from '@shared/domain/domainobject'; +import { SchoolYearEntity, User } from '@shared/domain/entity'; +import { Permission, SortOrder } from '@shared/domain/interface'; +import { + groupFactory, + roleDtoFactory, + schoolYearFactory, + setupEntities, + UserAndAccountTestFactory, + userDoFactory, + userFactory, +} from '@shared/testing'; +import { ClassRequestContext, SchoolYearQueryType } from '../controller/dto/interface'; +import { Group, IGroupFilter } from '../domain'; +import { UnknownQueryTypeLoggableException } from '../loggable'; +import { GroupService } from '../service'; +import { ClassInfoDto } from './dto'; +import { ClassRootType } from './dto/class-root-type'; + +describe('ClassGroupUc', () => { + let module: TestingModule; + let uc: ClassGroupUc; + + let groupService: DeepMocked; + let userService: DeepMocked; + let roleService: DeepMocked; + let classService: DeepMocked; + let systemService: DeepMocked; + let schoolService: DeepMocked; + let authorizationService: DeepMocked; + let schoolYearService: DeepMocked; + let courseService: DeepMocked; + let configService: DeepMocked>; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + ClassGroupUc, + { + provide: GroupService, + useValue: createMock(), + }, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: RoleService, + useValue: createMock(), + }, + { + provide: ClassService, + useValue: createMock(), + }, + { + provide: LegacySystemService, + useValue: createMock(), + }, + { + provide: SchoolService, + useValue: createMock(), + }, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: SchoolYearService, + useValue: createMock(), + }, + { + provide: CourseDoService, + useValue: createMock(), + }, + { + provide: ConfigService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(ClassGroupUc); + groupService = module.get(GroupService); + userService = module.get(UserService); + roleService = module.get(RoleService); + classService = module.get(ClassService); + systemService = module.get(LegacySystemService); + schoolService = module.get(SchoolService); + authorizationService = module.get(AuthorizationService); + schoolYearService = module.get(SchoolYearService); + courseService = module.get(CourseDoService); + configService = module.get(ConfigService); + + await setupEntities(); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('findAllClasses', () => { + describe('when the user has no permission', () => { + const setup = () => { + const school: School = schoolFactory.build(); + const user: User = userFactory.buildWithId(); + const error = new ForbiddenException(); + + schoolService.getSchoolById.mockResolvedValue(school); + authorizationService.getUserWithPermissions.mockResolvedValue(user); + authorizationService.checkPermission.mockImplementation(() => { + throw error; + }); + authorizationService.hasAllPermissions.mockReturnValueOnce(false); + + return { + user, + error, + }; + }; + + it('should throw forbidden', async () => { + const { user, error } = setup(); + + const func = () => uc.findAllClasses(user.id, user.school.id); + + await expect(func).rejects.toThrow(error); + }); + }); + + describe('when accessing as a normal user', () => { + const setup = () => { + const school: School = schoolFactory.build({ permissions: { teacher: { STUDENT_LIST: true } } }); + const { studentUser } = UserAndAccountTestFactory.buildStudent(); + const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const teacherRole: RoleDto = roleDtoFactory.buildWithId({ + id: teacherUser.roles[0].id, + name: teacherUser.roles[0].name, + }); + const studentRole: RoleDto = roleDtoFactory.buildWithId({ + id: studentUser.roles[0].id, + name: studentUser.roles[0].name, + }); + const teacherUserDo: UserDO = userDoFactory.buildWithId({ + id: teacherUser.id, + lastName: teacherUser.lastName, + roles: [{ id: teacherUser.roles[0].id, name: teacherUser.roles[0].name }], + }); + const studentUserDo: UserDO = userDoFactory.buildWithId({ + id: studentUser.id, + lastName: studentUser.lastName, + roles: [{ id: studentUser.roles[0].id, name: studentUser.roles[0].name }], + }); + const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); + const nextSchoolYear: SchoolYearEntity = schoolYearFactory.buildWithId({ + startDate: schoolYear.endDate, + }); + const clazz: Class = classFactory.build({ + name: 'A', + teacherIds: [teacherUser.id], + source: 'LDAP', + year: schoolYear.id, + }); + const successorClass: Class = classFactory.build({ + name: 'NEW', + teacherIds: [teacherUser.id], + year: nextSchoolYear.id, + }); + const classWithoutSchoolYear = classFactory.build({ + name: 'NoYear', + teacherIds: [teacherUser.id], + year: undefined, + }); + + const system: SystemDto = new SystemDto({ + id: new ObjectId().toHexString(), + displayName: 'External System', + type: 'oauth2', + }); + const group: Group = groupFactory.build({ + name: 'B', + users: [{ userId: teacherUser.id, roleId: teacherUser.roles[0].id }], + externalSource: undefined, + }); + const groupWithSystem: Group = groupFactory.build({ + name: 'C', + externalSource: { externalId: 'externalId', systemId: system.id }, + users: [ + { userId: teacherUser.id, roleId: teacherUser.roles[0].id }, + { userId: studentUser.id, roleId: studentUser.roles[0].id }, + ], + }); + const synchronizedCourse: Course = courseFactory.build({ syncedWithGroup: group.id }); + + schoolService.getSchoolById.mockResolvedValueOnce(school); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(teacherUser); + authorizationService.hasAllPermissions.mockReturnValueOnce(false); + classService.findAllByUserId.mockResolvedValueOnce([clazz, successorClass, classWithoutSchoolYear]); + groupService.findGroups.mockResolvedValueOnce(new Page([group, groupWithSystem], 2)); + classService.findClassesForSchool.mockResolvedValueOnce([clazz, successorClass, classWithoutSchoolYear]); + groupService.findGroups.mockResolvedValueOnce(new Page([group, groupWithSystem], 2)); + systemService.findById.mockResolvedValue(system); + userService.findById.mockImplementation((userId: string): Promise => { + if (userId === teacherUser.id) { + return Promise.resolve(teacherUserDo); + } + + if (userId === studentUser.id) { + return Promise.resolve(studentUserDo); + } + + throw new Error(); + }); + userService.findByIdOrNull.mockImplementation((userId: string): Promise => { + if (userId === teacherUser.id) { + return Promise.resolve(teacherUserDo); + } + + if (userId === studentUser.id) { + return Promise.resolve(studentUserDo); + } + + throw new Error(); + }); + roleService.findById.mockImplementation((roleId: string): Promise => { + if (roleId === teacherUser.roles[0].id) { + return Promise.resolve(teacherRole); + } + + if (roleId === studentUser.roles[0].id) { + return Promise.resolve(studentRole); + } + + throw new Error(); + }); + schoolYearService.findById.mockResolvedValueOnce(schoolYear); + schoolYearService.findById.mockResolvedValueOnce(nextSchoolYear); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + configService.get.mockReturnValueOnce(true); + courseService.findBySyncedGroup.mockResolvedValueOnce([synchronizedCourse]); + courseService.findBySyncedGroup.mockResolvedValueOnce([]); + + return { + teacherUser, + school, + clazz, + successorClass, + classWithoutSchoolYear, + group, + groupWithSystem, + system, + schoolYear, + nextSchoolYear, + synchronizedCourse, + }; + }; + + it('should check the required permissions', async () => { + const { teacherUser, school } = setup(); + + await uc.findAllClasses(teacherUser.id, teacherUser.school.id, SchoolYearQueryType.CURRENT_YEAR); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith<[User, School, AuthorizationContext]>( + teacherUser, + school, + { + action: Action.read, + requiredPermissions: [Permission.CLASS_VIEW, Permission.GROUP_VIEW], + } + ); + }); + + it('should check the access to the full list', async () => { + const { teacherUser } = setup(); + + await uc.findAllClasses(teacherUser.id, teacherUser.school.id); + + expect(authorizationService.hasAllPermissions).toHaveBeenCalledWith<[User, string[]]>(teacherUser, [ + Permission.CLASS_FULL_ADMIN, + Permission.GROUP_FULL_ADMIN, + ]); + }); + + describe('when accessing form course as a teacher', () => { + it('should call findClassesForSchool method from classService', async () => { + const { teacherUser } = setup(); + + await uc.findAllClasses(teacherUser.id, teacherUser.school.id, undefined, ClassRequestContext.COURSE); + + expect(classService.findClassesForSchool).toHaveBeenCalled(); + }); + }); + + describe('when accessing form class overview as a teacher', () => { + it('should call findAllByUserId method from classService', async () => { + const { teacherUser } = setup(); + + await uc.findAllClasses(teacherUser.id, teacherUser.school.id, undefined, ClassRequestContext.CLASS_OVERVIEW); + + expect(classService.findAllByUserId).toHaveBeenCalled(); + }); + }); + + describe('when no pagination is given', () => { + it('should return all classes sorted by name', async () => { + const { + teacherUser, + clazz, + successorClass, + classWithoutSchoolYear, + group, + groupWithSystem, + system, + schoolYear, + nextSchoolYear, + synchronizedCourse, + } = setup(); + + const result: Page = await uc.findAllClasses(teacherUser.id, teacherUser.school.id, undefined); + + expect(result).toEqual>({ + data: [ + { + id: clazz.id, + name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, + type: ClassRootType.CLASS, + externalSourceName: clazz.source, + teacherNames: [], + schoolYear: schoolYear.name, + isUpgradable: false, + studentCount: 2, + }, + { + id: successorClass.id, + name: successorClass.gradeLevel + ? `${successorClass.gradeLevel}${successorClass.name}` + : successorClass.name, + type: ClassRootType.CLASS, + externalSourceName: successorClass.source, + teacherNames: [], + schoolYear: nextSchoolYear.name, + isUpgradable: false, + studentCount: 2, + }, + { + id: classWithoutSchoolYear.id, + name: classWithoutSchoolYear.gradeLevel + ? `${classWithoutSchoolYear.gradeLevel}${classWithoutSchoolYear.name}` + : classWithoutSchoolYear.name, + type: ClassRootType.CLASS, + externalSourceName: classWithoutSchoolYear.source, + teacherNames: [], + isUpgradable: false, + studentCount: 2, + }, + { + id: group.id, + name: group.name, + type: ClassRootType.GROUP, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [{ id: synchronizedCourse.id, name: synchronizedCourse.name }], + }, + { + id: groupWithSystem.id, + name: groupWithSystem.name, + type: ClassRootType.GROUP, + externalSourceName: system.displayName, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [], + }, + ], + total: 5, + }); + }); + + it('should call group service with userId and no pagination', async () => { + const { teacherUser } = setup(); + + await uc.findAllClasses(teacherUser.id, teacherUser.school.id); + + expect(groupService.findGroups).toHaveBeenCalledWith<[IGroupFilter]>({ userId: teacherUser.id }); + }); + }); + + describe('when sorting by external source name in descending order', () => { + it('should return all classes sorted by external source name in descending order', async () => { + const { + teacherUser, + clazz, + classWithoutSchoolYear, + group, + groupWithSystem, + system, + schoolYear, + synchronizedCourse, + } = setup(); + + const result: Page = await uc.findAllClasses( + teacherUser.id, + teacherUser.school.id, + SchoolYearQueryType.CURRENT_YEAR, + undefined, + undefined, + undefined, + 'externalSourceName', + SortOrder.desc + ); + + expect(result).toEqual>({ + data: [ + { + id: classWithoutSchoolYear.id, + name: classWithoutSchoolYear.gradeLevel + ? `${classWithoutSchoolYear.gradeLevel}${classWithoutSchoolYear.name}` + : classWithoutSchoolYear.name, + type: ClassRootType.CLASS, + externalSourceName: classWithoutSchoolYear.source, + teacherNames: [], + isUpgradable: false, + studentCount: 2, + }, + { + id: clazz.id, + name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, + type: ClassRootType.CLASS, + externalSourceName: clazz.source, + teacherNames: [], + schoolYear: schoolYear.name, + isUpgradable: false, + studentCount: 2, + }, + { + id: groupWithSystem.id, + name: groupWithSystem.name, + type: ClassRootType.GROUP, + externalSourceName: system.displayName, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [], + }, + { + id: group.id, + name: group.name, + type: ClassRootType.GROUP, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [{ id: synchronizedCourse.id, name: synchronizedCourse.name }], + }, + ], + total: 4, + }); + }); + }); + + describe('when using pagination', () => { + it('should return the selected page', async () => { + const { teacherUser, group, synchronizedCourse } = setup(); + + const result: Page = await uc.findAllClasses( + teacherUser.id, + teacherUser.school.id, + SchoolYearQueryType.CURRENT_YEAR, + undefined, + 2, + 1, + 'name', + SortOrder.asc + ); + + expect(result).toEqual>({ + data: [ + { + id: group.id, + name: group.name, + type: ClassRootType.GROUP, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [{ id: synchronizedCourse.id, name: synchronizedCourse.name }], + }, + ], + total: 4, + }); + }); + }); + + describe('when querying for classes from next school year', () => { + it('should only return classes from next school year', async () => { + const { teacherUser, successorClass, nextSchoolYear } = setup(); + + const result: Page = await uc.findAllClasses( + teacherUser.id, + teacherUser.school.id, + SchoolYearQueryType.NEXT_YEAR + ); + + expect(result).toEqual>({ + data: [ + { + id: successorClass.id, + name: successorClass.gradeLevel + ? `${successorClass.gradeLevel}${successorClass.name}` + : successorClass.name, + externalSourceName: successorClass.source, + type: ClassRootType.CLASS, + teacherNames: [], + schoolYear: nextSchoolYear.name, + isUpgradable: false, + studentCount: 2, + }, + ], + total: 1, + }); + }); + }); + + describe('when querying for archived classes', () => { + it('should only return classes from previous school years', async () => { + const { teacherUser } = setup(); + + const result: Page = await uc.findAllClasses( + teacherUser.id, + teacherUser.school.id, + SchoolYearQueryType.PREVIOUS_YEARS + ); + + expect(result).toEqual>({ + data: [], + total: 0, + }); + }); + }); + + describe('when querying for not existing type', () => { + it('should throw', async () => { + const { teacherUser } = setup(); + + const func = async () => + uc.findAllClasses(teacherUser.id, teacherUser.school.id, 'notAType' as SchoolYearQueryType); + + await expect(func).rejects.toThrow(UnknownQueryTypeLoggableException); + }); + }); + }); + + describe('when accessing as a user with elevated permission', () => { + const setup = (generateClasses = false) => { + const school: School = schoolFactory.build(); + const { studentUser } = UserAndAccountTestFactory.buildStudent(); + const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const { adminUser } = UserAndAccountTestFactory.buildAdmin(); + const teacherRole: RoleDto = roleDtoFactory.buildWithId({ + id: teacherUser.roles[0].id, + name: teacherUser.roles[0].name, + }); + const studentRole: RoleDto = roleDtoFactory.buildWithId({ + id: studentUser.roles[0].id, + name: studentUser.roles[0].name, + }); + const adminUserDo: UserDO = userDoFactory.buildWithId({ + id: adminUser.id, + lastName: adminUser.lastName, + roles: [{ id: adminUser.roles[0].id, name: adminUser.roles[0].name }], + }); + const teacherUserDo: UserDO = userDoFactory.buildWithId({ + id: teacherUser.id, + lastName: teacherUser.lastName, + roles: [{ id: teacherUser.roles[0].id, name: teacherUser.roles[0].name }], + }); + const studentUserDo: UserDO = userDoFactory.buildWithId({ + id: studentUser.id, + lastName: studentUser.lastName, + roles: [{ id: studentUser.roles[0].id, name: studentUser.roles[0].name }], + }); + const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); + let clazzes: Class[] = []; + if (generateClasses) { + clazzes = classFactory.buildList(11, { + name: 'A', + teacherIds: [teacherUser.id], + source: 'LDAP', + year: schoolYear.id, + }); + } + const clazz: Class = classFactory.build({ + name: 'A', + teacherIds: [teacherUser.id], + source: 'LDAP', + year: schoolYear.id, + }); + const system: SystemDto = new SystemDto({ + id: new ObjectId().toHexString(), + displayName: 'External System', + type: 'oauth2', + }); + const group: Group = groupFactory.build({ + name: 'B', + users: [{ userId: teacherUser.id, roleId: teacherUser.roles[0].id }], + externalSource: undefined, + }); + const groupWithSystem: Group = groupFactory.build({ + name: 'C', + externalSource: { externalId: 'externalId', systemId: system.id }, + users: [ + { userId: teacherUser.id, roleId: teacherUser.roles[0].id }, + { userId: studentUser.id, roleId: studentUser.roles[0].id }, + ], + }); + + schoolService.getSchoolById.mockResolvedValueOnce(school); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(adminUser); + authorizationService.hasAllPermissions.mockReturnValueOnce(true); + classService.findClassesForSchool.mockResolvedValueOnce([...clazzes, clazz]); + groupService.findGroups.mockResolvedValueOnce(new Page([group, groupWithSystem], 2)); + systemService.findById.mockResolvedValue(system); + + userService.findById.mockImplementation((userId: string): Promise => { + if (userId === teacherUser.id) { + return Promise.resolve(teacherUserDo); + } + + if (userId === studentUser.id) { + return Promise.resolve(studentUserDo); + } + + if (userId === adminUser.id) { + return Promise.resolve(adminUserDo); + } + + throw new Error(); + }); + userService.findByIdOrNull.mockImplementation((userId: string): Promise => { + if (userId === teacherUser.id) { + return Promise.resolve(teacherUserDo); + } + + if (userId === studentUser.id) { + return Promise.resolve(studentUserDo); + } + + if (userId === adminUser.id) { + return Promise.resolve(adminUserDo); + } + + throw new Error(); + }); + roleService.findById.mockImplementation((roleId: string): Promise => { + if (roleId === teacherUser.roles[0].id) { + return Promise.resolve(teacherRole); + } + + if (roleId === studentUser.roles[0].id) { + return Promise.resolve(studentRole); + } + + throw new Error(); + }); + schoolYearService.findById.mockResolvedValue(schoolYear); + configService.get.mockReturnValueOnce(true); + courseService.findBySyncedGroup.mockResolvedValueOnce([]); + courseService.findBySyncedGroup.mockResolvedValueOnce([]); + + return { + adminUser, + teacherUser, + school, + clazz, + group, + groupWithSystem, + system, + schoolYear, + }; + }; + + it('should check the required permissions', async () => { + const { adminUser, school } = setup(); + + await uc.findAllClasses(adminUser.id, adminUser.school.id); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith<[User, School, AuthorizationContext]>( + adminUser, + school, + { + action: Action.read, + requiredPermissions: [Permission.CLASS_VIEW, Permission.GROUP_VIEW], + } + ); + }); + + it('should check the access to the full list', async () => { + const { adminUser } = setup(); + + await uc.findAllClasses(adminUser.id, adminUser.school.id); + + expect(authorizationService.hasAllPermissions).toHaveBeenCalledWith<[User, string[]]>(adminUser, [ + Permission.CLASS_FULL_ADMIN, + Permission.GROUP_FULL_ADMIN, + ]); + }); + + describe('when no pagination is given', () => { + it('should return all classes sorted by name', async () => { + const { adminUser, clazz, group, groupWithSystem, system, schoolYear } = setup(); + + const result: Page = await uc.findAllClasses(adminUser.id, adminUser.school.id); + + expect(result).toEqual>({ + data: [ + { + id: clazz.id, + name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, + type: ClassRootType.CLASS, + externalSourceName: clazz.source, + teacherNames: [], + schoolYear: schoolYear.name, + isUpgradable: false, + studentCount: 2, + }, + { + id: group.id, + name: group.name, + type: ClassRootType.GROUP, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [], + }, + { + id: groupWithSystem.id, + name: groupWithSystem.name, + type: ClassRootType.GROUP, + externalSourceName: system.displayName, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [], + }, + ], + total: 3, + }); + }); + + it('should call group service with schoolId and no pagination', async () => { + const { teacherUser } = setup(); + + await uc.findAllClasses(teacherUser.id, teacherUser.school.id); + + expect(groupService.findGroups).toHaveBeenCalledWith<[IGroupFilter]>({ schoolId: teacherUser.school.id }); + }); + }); + + describe('when sorting by external source name in descending order', () => { + it('should return all classes sorted by external source name in descending order', async () => { + const { adminUser, clazz, group, groupWithSystem, system, schoolYear } = setup(); + + const result: Page = await uc.findAllClasses( + adminUser.id, + adminUser.school.id, + undefined, + undefined, + undefined, + undefined, + 'externalSourceName', + SortOrder.desc + ); + + expect(result).toEqual>({ + data: [ + { + id: clazz.id, + name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, + type: ClassRootType.CLASS, + externalSourceName: clazz.source, + teacherNames: [], + schoolYear: schoolYear.name, + isUpgradable: false, + studentCount: 2, + }, + { + id: groupWithSystem.id, + name: groupWithSystem.name, + type: ClassRootType.GROUP, + externalSourceName: system.displayName, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [], + }, + { + id: group.id, + name: group.name, + type: ClassRootType.GROUP, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [], + }, + ], + total: 3, + }); + }); + }); + + describe('when using pagination', () => { + it('should return the selected page', async () => { + const { adminUser, group } = setup(); + + const result: Page = await uc.findAllClasses( + adminUser.id, + adminUser.school.id, + undefined, + undefined, + 1, + 1, + 'name', + SortOrder.asc + ); + + expect(result).toEqual>({ + data: [ + { + id: group.id, + name: group.name, + type: ClassRootType.GROUP, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [], + }, + ], + total: 3, + }); + }); + + it('should return classes with expected limit', async () => { + const { adminUser } = setup(true); + + const result: Page = await uc.findAllClasses( + adminUser.id, + adminUser.school.id, + undefined, + undefined, + 0, + 5 + ); + + expect(result.data.length).toEqual(5); + }); + + it('should return all classes without limit', async () => { + const { adminUser } = setup(true); + + const result: Page = await uc.findAllClasses( + adminUser.id, + adminUser.school.id, + undefined, + undefined, + 0, + -1 + ); + + expect(result.data.length).toEqual(14); + }); + }); + }); + + describe('when class has a user referenced which is not existing', () => { + const setup = () => { + const school: School = schoolFactory.build(); + const notFoundReferenceId = new ObjectId().toHexString(); + const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + const teacherRole: RoleDto = roleDtoFactory.buildWithId({ + id: teacherUser.roles[0].id, + name: teacherUser.roles[0].name, + }); + + const teacherUserDo: UserDO = userDoFactory.buildWithId({ + id: teacherUser.id, + lastName: teacherUser.lastName, + roles: [{ id: teacherUser.roles[0].id, name: teacherUser.roles[0].name }], + }); + + const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); + const clazz: Class = classFactory.build({ + name: 'A', + teacherIds: [teacherUser.id, notFoundReferenceId], + source: 'LDAP', + year: schoolYear.id, + }); + const system: SystemDto = new SystemDto({ + id: new ObjectId().toHexString(), + displayName: 'External System', + type: 'oauth2', + }); + const group: Group = groupFactory.build({ + name: 'B', + users: [ + { userId: teacherUser.id, roleId: teacherUser.roles[0].id }, + { userId: notFoundReferenceId, roleId: teacherUser.roles[0].id }, + ], + externalSource: undefined, + }); + + schoolService.getSchoolById.mockResolvedValueOnce(school); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(teacherUser); + authorizationService.hasAllPermissions.mockReturnValueOnce(false); + classService.findAllByUserId.mockResolvedValueOnce([clazz]); + groupService.findGroups.mockResolvedValueOnce(new Page([group], 1)); + systemService.findById.mockResolvedValue(system); + + userService.findById.mockImplementation((userId: string): Promise => { + if (userId === teacherUser.id) { + return Promise.resolve(teacherUserDo); + } + + throw new Error(); + }); + userService.findByIdOrNull.mockImplementation((userId: string): Promise => { + if (userId === teacherUser.id) { + return Promise.resolve(teacherUserDo); + } + + if (userId === notFoundReferenceId) { + return Promise.resolve(null); + } + + throw new Error(); + }); + roleService.findById.mockImplementation((roleId: string): Promise => { + if (roleId === teacherUser.roles[0].id) { + return Promise.resolve(teacherRole); + } + + throw new Error(); + }); + schoolYearService.findById.mockResolvedValue(schoolYear); + configService.get.mockReturnValueOnce(true); + courseService.findBySyncedGroup.mockResolvedValueOnce([]); + + return { + teacherUser, + clazz, + group, + notFoundReferenceId, + schoolYear, + }; + }; + + it('should return class without missing user', async () => { + const { teacherUser, clazz, group, schoolYear } = setup(); + + const result = await uc.findAllClasses(teacherUser.id, teacherUser.school.id); + + expect(result).toEqual>({ + data: [ + { + id: clazz.id, + name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, + type: ClassRootType.CLASS, + externalSourceName: clazz.source, + teacherNames: [], + schoolYear: schoolYear.name, + isUpgradable: false, + studentCount: 2, + }, + { + id: group.id, + name: group.name, + type: ClassRootType.GROUP, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [], + }, + ], + total: 2, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/group/uc/class-group.uc.ts b/apps/server/src/modules/group/uc/class-group.uc.ts new file mode 100644 index 00000000000..e81df5df037 --- /dev/null +++ b/apps/server/src/modules/group/uc/class-group.uc.ts @@ -0,0 +1,303 @@ +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { ClassService } from '@modules/class'; +import { Class } from '@modules/class/domain'; +import { Course } from '@modules/learnroom/domain'; +import { CourseDoService } from '@modules/learnroom/service/course-do.service'; +import { SchoolYearService } from '@modules/legacy-school'; +import { ProvisioningConfig } from '@modules/provisioning'; +import { School, SchoolService } from '@modules/school/domain'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { SortHelper } from '@shared/common'; +import { Page, UserDO } from '@shared/domain/domainobject'; +import { SchoolYearEntity, User } from '@shared/domain/entity'; +import { Permission, SortOrder } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; +import { LegacySystemService, SystemDto } from '@src/modules/system'; +import { ClassRequestContext, SchoolYearQueryType } from '../controller/dto/interface'; +import { Group, IGroupFilter } from '../domain'; +import { UnknownQueryTypeLoggableException } from '../loggable'; +import { GroupService } from '../service'; +import { ClassInfoDto, ResolvedGroupUser } from './dto'; +import { GroupUcMapper } from './mapper/group-uc.mapper'; + +@Injectable() +export class ClassGroupUc { + constructor( + private readonly groupService: GroupService, + private readonly classService: ClassService, + private readonly systemService: LegacySystemService, + private readonly schoolService: SchoolService, + private readonly authorizationService: AuthorizationService, + private readonly schoolYearService: SchoolYearService, + private readonly courseService: CourseDoService, + private readonly configService: ConfigService + ) {} + + public async findAllClasses( + userId: EntityId, + schoolId: EntityId, + schoolYearQueryType?: SchoolYearQueryType, + calledFrom?: ClassRequestContext, + skip = 0, + limit?: number, + sortBy: keyof ClassInfoDto = 'name', + sortOrder: SortOrder = SortOrder.asc + ): Promise> { + const school: School = await this.schoolService.getSchoolById(schoolId); + + const user: User = await this.authorizationService.getUserWithPermissions(userId); + this.authorizationService.checkPermission( + user, + school, + AuthorizationContextBuilder.read([Permission.CLASS_VIEW, Permission.GROUP_VIEW]) + ); + + const canSeeFullList: boolean = this.authorizationService.hasAllPermissions(user, [ + Permission.CLASS_FULL_ADMIN, + Permission.GROUP_FULL_ADMIN, + ]); + + const calledFromCourse: boolean = + calledFrom === ClassRequestContext.COURSE && school.getPermissions()?.teacher?.STUDENT_LIST === true; + + let combinedClassInfo: ClassInfoDto[]; + if (canSeeFullList || calledFromCourse) { + combinedClassInfo = await this.findCombinedClassListForSchool(schoolId, schoolYearQueryType); + } else { + combinedClassInfo = await this.findCombinedClassListForUser(userId, schoolYearQueryType); + } + + combinedClassInfo.sort((a: ClassInfoDto, b: ClassInfoDto): number => + SortHelper.genericSortFunction(a[sortBy], b[sortBy], sortOrder) + ); + + const pageContent: ClassInfoDto[] = this.applyPagination(combinedClassInfo, skip, limit); + + const page: Page = new Page(pageContent, combinedClassInfo.length); + + return page; + } + + private async findCombinedClassListForSchool( + schoolId: EntityId, + schoolYearQueryType?: SchoolYearQueryType + ): Promise { + let classInfosFromGroups: ClassInfoDto[] = []; + + const classInfosFromClasses = await this.findClassesForSchool(schoolId, schoolYearQueryType); + + if (!schoolYearQueryType || schoolYearQueryType === SchoolYearQueryType.CURRENT_YEAR) { + classInfosFromGroups = await this.findGroupsForSchool(schoolId); + } + + const combinedClassInfo: ClassInfoDto[] = [...classInfosFromClasses, ...classInfosFromGroups]; + + return combinedClassInfo; + } + + private async findCombinedClassListForUser( + userId: EntityId, + schoolYearQueryType?: SchoolYearQueryType + ): Promise { + let classInfosFromGroups: ClassInfoDto[] = []; + + const classInfosFromClasses = await this.findClassesForUser(userId, schoolYearQueryType); + + if (!schoolYearQueryType || schoolYearQueryType === SchoolYearQueryType.CURRENT_YEAR) { + classInfosFromGroups = await this.findGroupsForUser(userId); + } + + const combinedClassInfo: ClassInfoDto[] = [...classInfosFromClasses, ...classInfosFromGroups]; + + return combinedClassInfo; + } + + private async findClassesForSchool( + schoolId: EntityId, + schoolYearQueryType?: SchoolYearQueryType + ): Promise { + const classes: Class[] = await this.classService.findClassesForSchool(schoolId); + + const classInfosFromClasses: ClassInfoDto[] = await this.getClassInfosFromClasses(classes, schoolYearQueryType); + + return classInfosFromClasses; + } + + private async findClassesForUser( + userId: EntityId, + schoolYearQueryType?: SchoolYearQueryType + ): Promise { + const classes: Class[] = await this.classService.findAllByUserId(userId); + + const classInfosFromClasses: ClassInfoDto[] = await this.getClassInfosFromClasses(classes, schoolYearQueryType); + + return classInfosFromClasses; + } + + private async getClassInfosFromClasses( + classes: Class[], + schoolYearQueryType?: SchoolYearQueryType + ): Promise { + const currentYear: SchoolYearEntity = await this.schoolYearService.getCurrentSchoolYear(); + + const classesWithSchoolYear: { clazz: Class; schoolYear?: SchoolYearEntity }[] = await this.addSchoolYearsToClasses( + classes + ); + + const filteredClassesForSchoolYear = classesWithSchoolYear.filter((classWithSchoolYear) => + this.isClassOfQueryType(currentYear, classWithSchoolYear.schoolYear, schoolYearQueryType) + ); + + const classInfosFromClasses: ClassInfoDto[] = this.mapClassInfosFromClasses(filteredClassesForSchoolYear); + + return classInfosFromClasses; + } + + private async addSchoolYearsToClasses(classes: Class[]): Promise<{ clazz: Class; schoolYear?: SchoolYearEntity }[]> { + const classesWithSchoolYear: { clazz: Class; schoolYear?: SchoolYearEntity }[] = await Promise.all( + classes.map(async (clazz: Class) => { + let schoolYear: SchoolYearEntity | undefined; + if (clazz.year) { + schoolYear = await this.schoolYearService.findById(clazz.year); + } + + return { + clazz, + schoolYear, + }; + }) + ); + + return classesWithSchoolYear; + } + + private isClassOfQueryType( + currentYear: SchoolYearEntity, + schoolYear?: SchoolYearEntity, + schoolYearQueryType?: SchoolYearQueryType + ): boolean { + if (schoolYearQueryType === undefined) { + return true; + } + + if (schoolYear === undefined) { + return schoolYearQueryType === SchoolYearQueryType.CURRENT_YEAR; + } + + switch (schoolYearQueryType) { + case SchoolYearQueryType.CURRENT_YEAR: + return schoolYear.startDate === currentYear.startDate; + case SchoolYearQueryType.NEXT_YEAR: + return schoolYear.startDate > currentYear.startDate; + case SchoolYearQueryType.PREVIOUS_YEARS: + return schoolYear.startDate < currentYear.startDate; + default: + throw new UnknownQueryTypeLoggableException(schoolYearQueryType); + } + } + + private mapClassInfosFromClasses( + filteredClassesForSchoolYear: { clazz: Class; schoolYear?: SchoolYearEntity }[] + ): ClassInfoDto[] { + const classInfosFromClasses: ClassInfoDto[] = filteredClassesForSchoolYear.map( + (classWithSchoolYear): ClassInfoDto => { + const teachers: UserDO[] = []; + + const mapped: ClassInfoDto = GroupUcMapper.mapClassToClassInfoDto( + classWithSchoolYear.clazz, + teachers, + classWithSchoolYear.schoolYear + ); + + return mapped; + } + ); + return classInfosFromClasses; + } + + private async findGroupsForSchool(schoolId: EntityId): Promise { + const filter: IGroupFilter = { schoolId }; + + const groups: Page = await this.groupService.findGroups(filter); + + const classInfosFromGroups: ClassInfoDto[] = await this.getClassInfosFromGroups(groups.data); + + return classInfosFromGroups; + } + + private async findGroupsForUser(userId: EntityId): Promise { + const filter: IGroupFilter = { userId }; + + const groups: Page = await this.groupService.findGroups(filter); + + const classInfosFromGroups: ClassInfoDto[] = await this.getClassInfosFromGroups(groups.data); + + return classInfosFromGroups; + } + + private async getClassInfosFromGroups(groups: Group[]): Promise { + const systemMap: Map = await this.findSystemNamesForGroups(groups); + + const classInfosFromGroups: ClassInfoDto[] = await Promise.all( + groups.map(async (group: Group): Promise => this.getClassInfoFromGroup(group, systemMap)) + ); + + return classInfosFromGroups; + } + + private async getClassInfoFromGroup(group: Group, systemMap: Map): Promise { + let system: SystemDto | undefined; + if (group.externalSource) { + system = systemMap.get(group.externalSource.systemId); + } + + const resolvedUsers: ResolvedGroupUser[] = []; + + let synchronizedCourses: Course[] = []; + if (this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { + synchronizedCourses = await this.courseService.findBySyncedGroup(group); + } + + const mapped: ClassInfoDto = GroupUcMapper.mapGroupToClassInfoDto( + group, + resolvedUsers, + synchronizedCourses, + system + ); + + return mapped; + } + + private async findSystemNamesForGroups(groups: Group[]): Promise> { + const systemIds: EntityId[] = groups + .map((group: Group): string | undefined => group.externalSource?.systemId) + .filter((systemId: string | undefined): systemId is EntityId => systemId !== undefined); + + const uniqueSystemIds: EntityId[] = Array.from(new Set(systemIds)); + + const systems: Map = new Map(); + + await Promise.all( + uniqueSystemIds.map(async (systemId: string): Promise => { + const system: SystemDto = await this.systemService.findById(systemId); + + systems.set(systemId, system); + }) + ); + + return systems; + } + + private applyPagination(combinedClassInfo: ClassInfoDto[], skip: number, limit: number | undefined): ClassInfoDto[] { + let page: ClassInfoDto[]; + + if (limit === -1) { + page = combinedClassInfo.slice(skip); + } else { + page = combinedClassInfo.slice(skip, limit ? skip + limit : combinedClassInfo.length); + } + + return page; + } +} diff --git a/apps/server/src/modules/group/uc/group.uc.spec.ts b/apps/server/src/modules/group/uc/group.uc.spec.ts index 51da9de9147..e4bb9af1da4 100644 --- a/apps/server/src/modules/group/uc/group.uc.spec.ts +++ b/apps/server/src/modules/group/uc/group.uc.spec.ts @@ -1,44 +1,31 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ObjectId } from '@mikro-orm/mongodb'; -import { Action, AuthorizationContext, AuthorizationService } from '@modules/authorization'; -import { ClassService } from '@modules/class'; -import { Class } from '@modules/class/domain'; -import { classFactory } from '@modules/class/domain/testing/factory/class.factory'; -import { Course } from '@modules/learnroom/domain'; -import { CourseDoService } from '@modules/learnroom/service/course-do.service'; -import { courseFactory } from '@modules/learnroom/testing'; -import { SchoolYearService } from '@modules/legacy-school'; +import { AuthorizationService } from '@modules/authorization'; import { ProvisioningConfig } from '@modules/provisioning'; import { RoleService } from '@modules/role'; import { RoleDto } from '@modules/role/service/dto/role.dto'; import { School, SchoolService } from '@modules/school/domain'; import { schoolFactory } from '@modules/school/testing'; -import { LegacySystemService, SystemDto } from '@modules/system'; import { UserService } from '@modules/user'; import { ForbiddenException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { Page, UserDO } from '@shared/domain/domainobject'; -import { Role, SchoolYearEntity, User } from '@shared/domain/entity'; -import { Permission, SortOrder } from '@shared/domain/interface'; +import { Role, User } from '@shared/domain/entity'; +import { Permission } from '@shared/domain/interface'; import { groupFactory, roleDtoFactory, roleFactory, - schoolYearFactory, setupEntities, UserAndAccountTestFactory, userDoFactory, userFactory, } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { ClassRequestContext, SchoolYearQueryType } from '../controller/dto/interface'; -import { Group, GroupTypes, IGroupFilter } from '../domain'; -import { UnknownQueryTypeLoggableException } from '../loggable'; +import { Group, GroupTypes } from '../domain'; import { GroupService } from '../service'; -import { ClassInfoDto, ResolvedGroupDto } from './dto'; -import { ClassRootType } from './dto/class-root-type'; +import { ResolvedGroupDto } from './dto'; import { GroupUc } from './group.uc'; describe('GroupUc', () => { @@ -46,14 +33,10 @@ describe('GroupUc', () => { let uc: GroupUc; let groupService: DeepMocked; - let classService: DeepMocked; - let systemService: DeepMocked; let userService: DeepMocked; let roleService: DeepMocked; let schoolService: DeepMocked; let authorizationService: DeepMocked; - let schoolYearService: DeepMocked; - let courseService: DeepMocked; let configService: DeepMocked>; // eslint-disable-next-line @typescript-eslint/no-unused-vars let logger: DeepMocked; @@ -66,14 +49,6 @@ describe('GroupUc', () => { provide: GroupService, useValue: createMock(), }, - { - provide: ClassService, - useValue: createMock(), - }, - { - provide: LegacySystemService, - useValue: createMock(), - }, { provide: UserService, useValue: createMock(), @@ -90,14 +65,6 @@ describe('GroupUc', () => { provide: AuthorizationService, useValue: createMock(), }, - { - provide: SchoolYearService, - useValue: createMock(), - }, - { - provide: CourseDoService, - useValue: createMock(), - }, { provide: ConfigService, useValue: createMock(), @@ -111,14 +78,10 @@ describe('GroupUc', () => { uc = module.get(GroupUc); groupService = module.get(GroupService); - classService = module.get(ClassService); - systemService = module.get(LegacySystemService); userService = module.get(UserService); roleService = module.get(RoleService); schoolService = module.get(SchoolService); authorizationService = module.get(AuthorizationService); - schoolYearService = module.get(SchoolYearService); - courseService = module.get(CourseDoService); configService = module.get(ConfigService); logger = module.get(Logger); @@ -133,882 +96,6 @@ describe('GroupUc', () => { jest.resetAllMocks(); }); - describe('findAllClasses', () => { - describe('when the user has no permission', () => { - const setup = () => { - const school: School = schoolFactory.build(); - const user: User = userFactory.buildWithId(); - const error = new ForbiddenException(); - - schoolService.getSchoolById.mockResolvedValue(school); - authorizationService.getUserWithPermissions.mockResolvedValue(user); - authorizationService.checkPermission.mockImplementation(() => { - throw error; - }); - authorizationService.hasAllPermissions.mockReturnValueOnce(false); - - return { - user, - error, - }; - }; - - it('should throw forbidden', async () => { - const { user, error } = setup(); - - const func = () => uc.findAllClasses(user.id, user.school.id); - - await expect(func).rejects.toThrow(error); - }); - }); - - describe('when accessing as a normal user', () => { - const setup = () => { - const school: School = schoolFactory.build({ permissions: { teacher: { STUDENT_LIST: true } } }); - const { studentUser } = UserAndAccountTestFactory.buildStudent(); - const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); - const teacherRole: RoleDto = roleDtoFactory.buildWithId({ - id: teacherUser.roles[0].id, - name: teacherUser.roles[0].name, - }); - const studentRole: RoleDto = roleDtoFactory.buildWithId({ - id: studentUser.roles[0].id, - name: studentUser.roles[0].name, - }); - const teacherUserDo: UserDO = userDoFactory.buildWithId({ - id: teacherUser.id, - lastName: teacherUser.lastName, - roles: [{ id: teacherUser.roles[0].id, name: teacherUser.roles[0].name }], - }); - const studentUserDo: UserDO = userDoFactory.buildWithId({ - id: studentUser.id, - lastName: studentUser.lastName, - roles: [{ id: studentUser.roles[0].id, name: studentUser.roles[0].name }], - }); - const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); - const nextSchoolYear: SchoolYearEntity = schoolYearFactory.buildWithId({ - startDate: schoolYear.endDate, - }); - const clazz: Class = classFactory.build({ - name: 'A', - teacherIds: [teacherUser.id], - source: 'LDAP', - year: schoolYear.id, - }); - const successorClass: Class = classFactory.build({ - name: 'NEW', - teacherIds: [teacherUser.id], - year: nextSchoolYear.id, - }); - const classWithoutSchoolYear = classFactory.build({ - name: 'NoYear', - teacherIds: [teacherUser.id], - year: undefined, - }); - - const system: SystemDto = new SystemDto({ - id: new ObjectId().toHexString(), - displayName: 'External System', - type: 'oauth2', - }); - const group: Group = groupFactory.build({ - name: 'B', - users: [{ userId: teacherUser.id, roleId: teacherUser.roles[0].id }], - externalSource: undefined, - }); - const groupWithSystem: Group = groupFactory.build({ - name: 'C', - externalSource: { externalId: 'externalId', systemId: system.id }, - users: [ - { userId: teacherUser.id, roleId: teacherUser.roles[0].id }, - { userId: studentUser.id, roleId: studentUser.roles[0].id }, - ], - }); - const synchronizedCourse: Course = courseFactory.build({ syncedWithGroup: group.id }); - - schoolService.getSchoolById.mockResolvedValueOnce(school); - authorizationService.getUserWithPermissions.mockResolvedValueOnce(teacherUser); - authorizationService.hasAllPermissions.mockReturnValueOnce(false); - classService.findAllByUserId.mockResolvedValueOnce([clazz, successorClass, classWithoutSchoolYear]); - groupService.findGroups.mockResolvedValueOnce(new Page([group, groupWithSystem], 2)); - classService.findClassesForSchool.mockResolvedValueOnce([clazz, successorClass, classWithoutSchoolYear]); - groupService.findGroups.mockResolvedValueOnce(new Page([group, groupWithSystem], 2)); - systemService.findById.mockResolvedValue(system); - userService.findById.mockImplementation((userId: string): Promise => { - if (userId === teacherUser.id) { - return Promise.resolve(teacherUserDo); - } - - if (userId === studentUser.id) { - return Promise.resolve(studentUserDo); - } - - throw new Error(); - }); - userService.findByIdOrNull.mockImplementation((userId: string): Promise => { - if (userId === teacherUser.id) { - return Promise.resolve(teacherUserDo); - } - - if (userId === studentUser.id) { - return Promise.resolve(studentUserDo); - } - - throw new Error(); - }); - roleService.findById.mockImplementation((roleId: string): Promise => { - if (roleId === teacherUser.roles[0].id) { - return Promise.resolve(teacherRole); - } - - if (roleId === studentUser.roles[0].id) { - return Promise.resolve(studentRole); - } - - throw new Error(); - }); - schoolYearService.findById.mockResolvedValueOnce(schoolYear); - schoolYearService.findById.mockResolvedValueOnce(nextSchoolYear); - schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); - configService.get.mockReturnValueOnce(true); - courseService.findBySyncedGroup.mockResolvedValueOnce([synchronizedCourse]); - courseService.findBySyncedGroup.mockResolvedValueOnce([]); - - return { - teacherUser, - school, - clazz, - successorClass, - classWithoutSchoolYear, - group, - groupWithSystem, - system, - schoolYear, - nextSchoolYear, - synchronizedCourse, - }; - }; - - it('should check the required permissions', async () => { - const { teacherUser, school } = setup(); - - await uc.findAllClasses(teacherUser.id, teacherUser.school.id, SchoolYearQueryType.CURRENT_YEAR); - - expect(authorizationService.checkPermission).toHaveBeenCalledWith<[User, School, AuthorizationContext]>( - teacherUser, - school, - { - action: Action.read, - requiredPermissions: [Permission.CLASS_VIEW, Permission.GROUP_VIEW], - } - ); - }); - - it('should check the access to the full list', async () => { - const { teacherUser } = setup(); - - await uc.findAllClasses(teacherUser.id, teacherUser.school.id); - - expect(authorizationService.hasAllPermissions).toHaveBeenCalledWith<[User, string[]]>(teacherUser, [ - Permission.CLASS_FULL_ADMIN, - Permission.GROUP_FULL_ADMIN, - ]); - }); - - describe('when accessing form course as a teacher', () => { - it('should call findClassesForSchool method from classService', async () => { - const { teacherUser } = setup(); - - await uc.findAllClasses(teacherUser.id, teacherUser.school.id, undefined, ClassRequestContext.COURSE); - - expect(classService.findClassesForSchool).toHaveBeenCalled(); - }); - }); - - describe('when accessing form class overview as a teacher', () => { - it('should call findAllByUserId method from classService', async () => { - const { teacherUser } = setup(); - - await uc.findAllClasses(teacherUser.id, teacherUser.school.id, undefined, ClassRequestContext.CLASS_OVERVIEW); - - expect(classService.findAllByUserId).toHaveBeenCalled(); - }); - }); - - describe('when no pagination is given', () => { - it('should return all classes sorted by name', async () => { - const { - teacherUser, - clazz, - successorClass, - classWithoutSchoolYear, - group, - groupWithSystem, - system, - schoolYear, - nextSchoolYear, - synchronizedCourse, - } = setup(); - - const result: Page = await uc.findAllClasses(teacherUser.id, teacherUser.school.id, undefined); - - expect(result).toEqual>({ - data: [ - { - id: clazz.id, - name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, - type: ClassRootType.CLASS, - externalSourceName: clazz.source, - teacherNames: [], - schoolYear: schoolYear.name, - isUpgradable: false, - studentCount: 2, - }, - { - id: successorClass.id, - name: successorClass.gradeLevel - ? `${successorClass.gradeLevel}${successorClass.name}` - : successorClass.name, - type: ClassRootType.CLASS, - externalSourceName: successorClass.source, - teacherNames: [], - schoolYear: nextSchoolYear.name, - isUpgradable: false, - studentCount: 2, - }, - { - id: classWithoutSchoolYear.id, - name: classWithoutSchoolYear.gradeLevel - ? `${classWithoutSchoolYear.gradeLevel}${classWithoutSchoolYear.name}` - : classWithoutSchoolYear.name, - type: ClassRootType.CLASS, - externalSourceName: classWithoutSchoolYear.source, - teacherNames: [], - isUpgradable: false, - studentCount: 2, - }, - { - id: group.id, - name: group.name, - type: ClassRootType.GROUP, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [{ id: synchronizedCourse.id, name: synchronizedCourse.name }], - }, - { - id: groupWithSystem.id, - name: groupWithSystem.name, - type: ClassRootType.GROUP, - externalSourceName: system.displayName, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [], - }, - ], - total: 5, - }); - }); - - it('should call group service with userId and no pagination', async () => { - const { teacherUser } = setup(); - - await uc.findAllClasses(teacherUser.id, teacherUser.school.id); - - expect(groupService.findGroups).toHaveBeenCalledWith<[IGroupFilter]>({ userId: teacherUser.id }); - }); - }); - - describe('when sorting by external source name in descending order', () => { - it('should return all classes sorted by external source name in descending order', async () => { - const { - teacherUser, - clazz, - classWithoutSchoolYear, - group, - groupWithSystem, - system, - schoolYear, - synchronizedCourse, - } = setup(); - - const result: Page = await uc.findAllClasses( - teacherUser.id, - teacherUser.school.id, - SchoolYearQueryType.CURRENT_YEAR, - undefined, - undefined, - undefined, - 'externalSourceName', - SortOrder.desc - ); - - expect(result).toEqual>({ - data: [ - { - id: classWithoutSchoolYear.id, - name: classWithoutSchoolYear.gradeLevel - ? `${classWithoutSchoolYear.gradeLevel}${classWithoutSchoolYear.name}` - : classWithoutSchoolYear.name, - type: ClassRootType.CLASS, - externalSourceName: classWithoutSchoolYear.source, - teacherNames: [], - isUpgradable: false, - studentCount: 2, - }, - { - id: clazz.id, - name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, - type: ClassRootType.CLASS, - externalSourceName: clazz.source, - teacherNames: [], - schoolYear: schoolYear.name, - isUpgradable: false, - studentCount: 2, - }, - { - id: groupWithSystem.id, - name: groupWithSystem.name, - type: ClassRootType.GROUP, - externalSourceName: system.displayName, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [], - }, - { - id: group.id, - name: group.name, - type: ClassRootType.GROUP, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [{ id: synchronizedCourse.id, name: synchronizedCourse.name }], - }, - ], - total: 4, - }); - }); - }); - - describe('when using pagination', () => { - it('should return the selected page', async () => { - const { teacherUser, group, synchronizedCourse } = setup(); - - const result: Page = await uc.findAllClasses( - teacherUser.id, - teacherUser.school.id, - SchoolYearQueryType.CURRENT_YEAR, - undefined, - 2, - 1, - 'name', - SortOrder.asc - ); - - expect(result).toEqual>({ - data: [ - { - id: group.id, - name: group.name, - type: ClassRootType.GROUP, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [{ id: synchronizedCourse.id, name: synchronizedCourse.name }], - }, - ], - total: 4, - }); - }); - }); - - describe('when querying for classes from next school year', () => { - it('should only return classes from next school year', async () => { - const { teacherUser, successorClass, nextSchoolYear } = setup(); - - const result: Page = await uc.findAllClasses( - teacherUser.id, - teacherUser.school.id, - SchoolYearQueryType.NEXT_YEAR - ); - - expect(result).toEqual>({ - data: [ - { - id: successorClass.id, - name: successorClass.gradeLevel - ? `${successorClass.gradeLevel}${successorClass.name}` - : successorClass.name, - externalSourceName: successorClass.source, - type: ClassRootType.CLASS, - teacherNames: [], - schoolYear: nextSchoolYear.name, - isUpgradable: false, - studentCount: 2, - }, - ], - total: 1, - }); - }); - }); - - describe('when querying for archived classes', () => { - it('should only return classes from previous school years', async () => { - const { teacherUser } = setup(); - - const result: Page = await uc.findAllClasses( - teacherUser.id, - teacherUser.school.id, - SchoolYearQueryType.PREVIOUS_YEARS - ); - - expect(result).toEqual>({ - data: [], - total: 0, - }); - }); - }); - - describe('when querying for not existing type', () => { - it('should throw', async () => { - const { teacherUser } = setup(); - - const func = async () => - uc.findAllClasses(teacherUser.id, teacherUser.school.id, 'notAType' as SchoolYearQueryType); - - await expect(func).rejects.toThrow(UnknownQueryTypeLoggableException); - }); - }); - }); - - describe('when accessing as a user with elevated permission', () => { - const setup = (generateClasses = false) => { - const school: School = schoolFactory.build(); - const { studentUser } = UserAndAccountTestFactory.buildStudent(); - const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); - const { adminUser } = UserAndAccountTestFactory.buildAdmin(); - const teacherRole: RoleDto = roleDtoFactory.buildWithId({ - id: teacherUser.roles[0].id, - name: teacherUser.roles[0].name, - }); - const studentRole: RoleDto = roleDtoFactory.buildWithId({ - id: studentUser.roles[0].id, - name: studentUser.roles[0].name, - }); - const adminUserDo: UserDO = userDoFactory.buildWithId({ - id: adminUser.id, - lastName: adminUser.lastName, - roles: [{ id: adminUser.roles[0].id, name: adminUser.roles[0].name }], - }); - const teacherUserDo: UserDO = userDoFactory.buildWithId({ - id: teacherUser.id, - lastName: teacherUser.lastName, - roles: [{ id: teacherUser.roles[0].id, name: teacherUser.roles[0].name }], - }); - const studentUserDo: UserDO = userDoFactory.buildWithId({ - id: studentUser.id, - lastName: studentUser.lastName, - roles: [{ id: studentUser.roles[0].id, name: studentUser.roles[0].name }], - }); - const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); - let clazzes: Class[] = []; - if (generateClasses) { - clazzes = classFactory.buildList(11, { - name: 'A', - teacherIds: [teacherUser.id], - source: 'LDAP', - year: schoolYear.id, - }); - } - const clazz: Class = classFactory.build({ - name: 'A', - teacherIds: [teacherUser.id], - source: 'LDAP', - year: schoolYear.id, - }); - const system: SystemDto = new SystemDto({ - id: new ObjectId().toHexString(), - displayName: 'External System', - type: 'oauth2', - }); - const group: Group = groupFactory.build({ - name: 'B', - users: [{ userId: teacherUser.id, roleId: teacherUser.roles[0].id }], - externalSource: undefined, - }); - const groupWithSystem: Group = groupFactory.build({ - name: 'C', - externalSource: { externalId: 'externalId', systemId: system.id }, - users: [ - { userId: teacherUser.id, roleId: teacherUser.roles[0].id }, - { userId: studentUser.id, roleId: studentUser.roles[0].id }, - ], - }); - - schoolService.getSchoolById.mockResolvedValueOnce(school); - authorizationService.getUserWithPermissions.mockResolvedValueOnce(adminUser); - authorizationService.hasAllPermissions.mockReturnValueOnce(true); - classService.findClassesForSchool.mockResolvedValueOnce([...clazzes, clazz]); - groupService.findGroups.mockResolvedValueOnce(new Page([group, groupWithSystem], 2)); - systemService.findById.mockResolvedValue(system); - - userService.findById.mockImplementation((userId: string): Promise => { - if (userId === teacherUser.id) { - return Promise.resolve(teacherUserDo); - } - - if (userId === studentUser.id) { - return Promise.resolve(studentUserDo); - } - - if (userId === adminUser.id) { - return Promise.resolve(adminUserDo); - } - - throw new Error(); - }); - userService.findByIdOrNull.mockImplementation((userId: string): Promise => { - if (userId === teacherUser.id) { - return Promise.resolve(teacherUserDo); - } - - if (userId === studentUser.id) { - return Promise.resolve(studentUserDo); - } - - if (userId === adminUser.id) { - return Promise.resolve(adminUserDo); - } - - throw new Error(); - }); - roleService.findById.mockImplementation((roleId: string): Promise => { - if (roleId === teacherUser.roles[0].id) { - return Promise.resolve(teacherRole); - } - - if (roleId === studentUser.roles[0].id) { - return Promise.resolve(studentRole); - } - - throw new Error(); - }); - schoolYearService.findById.mockResolvedValue(schoolYear); - configService.get.mockReturnValueOnce(true); - courseService.findBySyncedGroup.mockResolvedValueOnce([]); - courseService.findBySyncedGroup.mockResolvedValueOnce([]); - - return { - adminUser, - teacherUser, - school, - clazz, - group, - groupWithSystem, - system, - schoolYear, - }; - }; - - it('should check the required permissions', async () => { - const { adminUser, school } = setup(); - - await uc.findAllClasses(adminUser.id, adminUser.school.id); - - expect(authorizationService.checkPermission).toHaveBeenCalledWith<[User, School, AuthorizationContext]>( - adminUser, - school, - { - action: Action.read, - requiredPermissions: [Permission.CLASS_VIEW, Permission.GROUP_VIEW], - } - ); - }); - - it('should check the access to the full list', async () => { - const { adminUser } = setup(); - - await uc.findAllClasses(adminUser.id, adminUser.school.id); - - expect(authorizationService.hasAllPermissions).toHaveBeenCalledWith<[User, string[]]>(adminUser, [ - Permission.CLASS_FULL_ADMIN, - Permission.GROUP_FULL_ADMIN, - ]); - }); - - describe('when no pagination is given', () => { - it('should return all classes sorted by name', async () => { - const { adminUser, clazz, group, groupWithSystem, system, schoolYear } = setup(); - - const result: Page = await uc.findAllClasses(adminUser.id, adminUser.school.id); - - expect(result).toEqual>({ - data: [ - { - id: clazz.id, - name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, - type: ClassRootType.CLASS, - externalSourceName: clazz.source, - teacherNames: [], - schoolYear: schoolYear.name, - isUpgradable: false, - studentCount: 2, - }, - { - id: group.id, - name: group.name, - type: ClassRootType.GROUP, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [], - }, - { - id: groupWithSystem.id, - name: groupWithSystem.name, - type: ClassRootType.GROUP, - externalSourceName: system.displayName, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [], - }, - ], - total: 3, - }); - }); - - it('should call group service with schoolId and no pagination', async () => { - const { teacherUser } = setup(); - - await uc.findAllClasses(teacherUser.id, teacherUser.school.id); - - expect(groupService.findGroups).toHaveBeenCalledWith<[IGroupFilter]>({ schoolId: teacherUser.school.id }); - }); - }); - - describe('when sorting by external source name in descending order', () => { - it('should return all classes sorted by external source name in descending order', async () => { - const { adminUser, clazz, group, groupWithSystem, system, schoolYear } = setup(); - - const result: Page = await uc.findAllClasses( - adminUser.id, - adminUser.school.id, - undefined, - undefined, - undefined, - undefined, - 'externalSourceName', - SortOrder.desc - ); - - expect(result).toEqual>({ - data: [ - { - id: clazz.id, - name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, - type: ClassRootType.CLASS, - externalSourceName: clazz.source, - teacherNames: [], - schoolYear: schoolYear.name, - isUpgradable: false, - studentCount: 2, - }, - { - id: groupWithSystem.id, - name: groupWithSystem.name, - type: ClassRootType.GROUP, - externalSourceName: system.displayName, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [], - }, - { - id: group.id, - name: group.name, - type: ClassRootType.GROUP, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [], - }, - ], - total: 3, - }); - }); - }); - - describe('when using pagination', () => { - it('should return the selected page', async () => { - const { adminUser, group } = setup(); - - const result: Page = await uc.findAllClasses( - adminUser.id, - adminUser.school.id, - undefined, - undefined, - 1, - 1, - 'name', - SortOrder.asc - ); - - expect(result).toEqual>({ - data: [ - { - id: group.id, - name: group.name, - type: ClassRootType.GROUP, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [], - }, - ], - total: 3, - }); - }); - - it('should return classes with expected limit', async () => { - const { adminUser } = setup(true); - - const result: Page = await uc.findAllClasses( - adminUser.id, - adminUser.school.id, - undefined, - undefined, - 0, - 5 - ); - - expect(result.data.length).toEqual(5); - }); - - it('should return all classes without limit', async () => { - const { adminUser } = setup(true); - - const result: Page = await uc.findAllClasses( - adminUser.id, - adminUser.school.id, - undefined, - undefined, - 0, - -1 - ); - - expect(result.data.length).toEqual(14); - }); - }); - }); - - describe('when class has a user referenced which is not existing', () => { - const setup = () => { - const school: School = schoolFactory.build(); - const notFoundReferenceId = new ObjectId().toHexString(); - const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); - - const teacherRole: RoleDto = roleDtoFactory.buildWithId({ - id: teacherUser.roles[0].id, - name: teacherUser.roles[0].name, - }); - - const teacherUserDo: UserDO = userDoFactory.buildWithId({ - id: teacherUser.id, - lastName: teacherUser.lastName, - roles: [{ id: teacherUser.roles[0].id, name: teacherUser.roles[0].name }], - }); - - const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); - const clazz: Class = classFactory.build({ - name: 'A', - teacherIds: [teacherUser.id, notFoundReferenceId], - source: 'LDAP', - year: schoolYear.id, - }); - const system: SystemDto = new SystemDto({ - id: new ObjectId().toHexString(), - displayName: 'External System', - type: 'oauth2', - }); - const group: Group = groupFactory.build({ - name: 'B', - users: [ - { userId: teacherUser.id, roleId: teacherUser.roles[0].id }, - { userId: notFoundReferenceId, roleId: teacherUser.roles[0].id }, - ], - externalSource: undefined, - }); - - schoolService.getSchoolById.mockResolvedValueOnce(school); - authorizationService.getUserWithPermissions.mockResolvedValueOnce(teacherUser); - authorizationService.hasAllPermissions.mockReturnValueOnce(false); - classService.findAllByUserId.mockResolvedValueOnce([clazz]); - groupService.findGroups.mockResolvedValueOnce(new Page([group], 1)); - systemService.findById.mockResolvedValue(system); - - userService.findById.mockImplementation((userId: string): Promise => { - if (userId === teacherUser.id) { - return Promise.resolve(teacherUserDo); - } - - throw new Error(); - }); - userService.findByIdOrNull.mockImplementation((userId: string): Promise => { - if (userId === teacherUser.id) { - return Promise.resolve(teacherUserDo); - } - - if (userId === notFoundReferenceId) { - return Promise.resolve(null); - } - - throw new Error(); - }); - roleService.findById.mockImplementation((roleId: string): Promise => { - if (roleId === teacherUser.roles[0].id) { - return Promise.resolve(teacherRole); - } - - throw new Error(); - }); - schoolYearService.findById.mockResolvedValue(schoolYear); - configService.get.mockReturnValueOnce(true); - courseService.findBySyncedGroup.mockResolvedValueOnce([]); - - return { - teacherUser, - clazz, - group, - notFoundReferenceId, - schoolYear, - }; - }; - - it('should return class without missing user', async () => { - const { teacherUser, clazz, group, schoolYear } = setup(); - - const result = await uc.findAllClasses(teacherUser.id, teacherUser.school.id); - - expect(result).toEqual>({ - data: [ - { - id: clazz.id, - name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, - type: ClassRootType.CLASS, - externalSourceName: clazz.source, - teacherNames: [], - schoolYear: schoolYear.name, - isUpgradable: false, - studentCount: 2, - }, - { - id: group.id, - name: group.name, - type: ClassRootType.GROUP, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [], - }, - ], - total: 2, - }); - }); - }); - }); - describe('getGroup', () => { describe('when the user has no permission', () => { const setup = () => { diff --git a/apps/server/src/modules/group/uc/group.uc.ts b/apps/server/src/modules/group/uc/group.uc.ts index 6f462c48283..74b9de16ca1 100644 --- a/apps/server/src/modules/group/uc/group.uc.ts +++ b/apps/server/src/modules/group/uc/group.uc.ts @@ -1,299 +1,50 @@ import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; -import { ClassService } from '@modules/class'; -import { Class } from '@modules/class/domain'; -import { Course } from '@modules/learnroom/domain'; -import { CourseDoService } from '@modules/learnroom/service/course-do.service'; -import { SchoolYearService } from '@modules/legacy-school'; import { ProvisioningConfig } from '@modules/provisioning'; -import { RoleService } from '@modules/role'; -import { RoleDto } from '@modules/role/service/dto/role.dto'; +import { RoleDto, RoleService } from '@modules/role'; import { School, SchoolService } from '@modules/school/domain'; import { UserService } from '@modules/user'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { SortHelper } from '@shared/common'; import { Page, UserDO } from '@shared/domain/domainobject'; -import { SchoolYearEntity, User } from '@shared/domain/entity'; +import { User } from '@shared/domain/entity'; import { IFindOptions, Permission, SortOrder } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { Logger } from '@src/core/logger'; -import { LegacySystemService, SystemDto } from '@src/modules/system'; -import { ClassRequestContext, SchoolYearQueryType } from '../controller/dto/interface'; import { Group, GroupUser, IGroupFilter } from '../domain'; -import { UnknownQueryTypeLoggableException } from '../loggable'; import { GroupService } from '../service'; -import { ClassInfoDto, ResolvedGroupDto, ResolvedGroupUser } from './dto'; +import { ResolvedGroupDto, ResolvedGroupUser } from './dto'; import { GroupUcMapper } from './mapper/group-uc.mapper'; @Injectable() export class GroupUc { constructor( private readonly groupService: GroupService, - private readonly classService: ClassService, - private readonly systemService: LegacySystemService, private readonly userService: UserService, private readonly roleService: RoleService, private readonly schoolService: SchoolService, private readonly authorizationService: AuthorizationService, - private readonly schoolYearService: SchoolYearService, - private readonly courseService: CourseDoService, private readonly configService: ConfigService, private readonly logger: Logger ) {} - public async findAllClasses( - userId: EntityId, - schoolId: EntityId, - schoolYearQueryType?: SchoolYearQueryType, - calledFrom?: ClassRequestContext, - skip = 0, - limit?: number, - sortBy: keyof ClassInfoDto = 'name', - sortOrder: SortOrder = SortOrder.asc - ): Promise> { - const school: School = await this.schoolService.getSchoolById(schoolId); - - const user: User = await this.authorizationService.getUserWithPermissions(userId); - this.authorizationService.checkPermission( - user, - school, - AuthorizationContextBuilder.read([Permission.CLASS_VIEW, Permission.GROUP_VIEW]) - ); - - const canSeeFullList: boolean = this.authorizationService.hasAllPermissions(user, [ - Permission.CLASS_FULL_ADMIN, - Permission.GROUP_FULL_ADMIN, - ]); - - const calledFromCourse: boolean = - calledFrom === ClassRequestContext.COURSE && school.getPermissions()?.teacher?.STUDENT_LIST === true; - - let combinedClassInfo: ClassInfoDto[]; - if (canSeeFullList || calledFromCourse) { - combinedClassInfo = await this.findCombinedClassListForSchool(schoolId, schoolYearQueryType); - } else { - combinedClassInfo = await this.findCombinedClassListForUser(userId, schoolYearQueryType); - } - - combinedClassInfo.sort((a: ClassInfoDto, b: ClassInfoDto): number => - SortHelper.genericSortFunction(a[sortBy], b[sortBy], sortOrder) - ); - - const pageContent: ClassInfoDto[] = this.applyPagination(combinedClassInfo, skip, limit); - - const page: Page = new Page(pageContent, combinedClassInfo.length); - - return page; - } - - private async findCombinedClassListForSchool( - schoolId: EntityId, - schoolYearQueryType?: SchoolYearQueryType - ): Promise { - let classInfosFromGroups: ClassInfoDto[] = []; - - const classInfosFromClasses = await this.findClassesForSchool(schoolId, schoolYearQueryType); - - if (!schoolYearQueryType || schoolYearQueryType === SchoolYearQueryType.CURRENT_YEAR) { - classInfosFromGroups = await this.findGroupsForSchool(schoolId); - } - - const combinedClassInfo: ClassInfoDto[] = [...classInfosFromClasses, ...classInfosFromGroups]; - - return combinedClassInfo; - } - - private async findCombinedClassListForUser( - userId: EntityId, - schoolYearQueryType?: SchoolYearQueryType - ): Promise { - let classInfosFromGroups: ClassInfoDto[] = []; - - const classInfosFromClasses = await this.findClassesForUser(userId, schoolYearQueryType); - - if (!schoolYearQueryType || schoolYearQueryType === SchoolYearQueryType.CURRENT_YEAR) { - classInfosFromGroups = await this.findGroupsForUser(userId); - } - - const combinedClassInfo: ClassInfoDto[] = [...classInfosFromClasses, ...classInfosFromGroups]; - - return combinedClassInfo; - } - - private async findClassesForSchool( - schoolId: EntityId, - schoolYearQueryType?: SchoolYearQueryType - ): Promise { - const classes: Class[] = await this.classService.findClassesForSchool(schoolId); - - const classInfosFromClasses: ClassInfoDto[] = await this.getClassInfosFromClasses(classes, schoolYearQueryType); - - return classInfosFromClasses; - } - - private async findClassesForUser( - userId: EntityId, - schoolYearQueryType?: SchoolYearQueryType - ): Promise { - const classes: Class[] = await this.classService.findAllByUserId(userId); - - const classInfosFromClasses: ClassInfoDto[] = await this.getClassInfosFromClasses(classes, schoolYearQueryType); - - return classInfosFromClasses; - } - - private async getClassInfosFromClasses( - classes: Class[], - schoolYearQueryType?: SchoolYearQueryType - ): Promise { - const currentYear: SchoolYearEntity = await this.schoolYearService.getCurrentSchoolYear(); - - const classesWithSchoolYear: { clazz: Class; schoolYear?: SchoolYearEntity }[] = await this.addSchoolYearsToClasses( - classes - ); - - const filteredClassesForSchoolYear = classesWithSchoolYear.filter((classWithSchoolYear) => - this.isClassOfQueryType(currentYear, classWithSchoolYear.schoolYear, schoolYearQueryType) - ); - - const classInfosFromClasses: ClassInfoDto[] = this.mapClassInfosFromClasses(filteredClassesForSchoolYear); - - return classInfosFromClasses; - } - - private async addSchoolYearsToClasses(classes: Class[]): Promise<{ clazz: Class; schoolYear?: SchoolYearEntity }[]> { - const classesWithSchoolYear: { clazz: Class; schoolYear?: SchoolYearEntity }[] = await Promise.all( - classes.map(async (clazz: Class) => { - let schoolYear: SchoolYearEntity | undefined; - if (clazz.year) { - schoolYear = await this.schoolYearService.findById(clazz.year); - } - - return { - clazz, - schoolYear, - }; - }) - ); - - return classesWithSchoolYear; - } - - private isClassOfQueryType( - currentYear: SchoolYearEntity, - schoolYear?: SchoolYearEntity, - schoolYearQueryType?: SchoolYearQueryType - ): boolean { - if (schoolYearQueryType === undefined) { - return true; - } - - if (schoolYear === undefined) { - return schoolYearQueryType === SchoolYearQueryType.CURRENT_YEAR; - } - - switch (schoolYearQueryType) { - case SchoolYearQueryType.CURRENT_YEAR: - return schoolYear.startDate === currentYear.startDate; - case SchoolYearQueryType.NEXT_YEAR: - return schoolYear.startDate > currentYear.startDate; - case SchoolYearQueryType.PREVIOUS_YEARS: - return schoolYear.startDate < currentYear.startDate; - default: - throw new UnknownQueryTypeLoggableException(schoolYearQueryType); - } - } - - private mapClassInfosFromClasses( - filteredClassesForSchoolYear: { clazz: Class; schoolYear?: SchoolYearEntity }[] - ): ClassInfoDto[] { - const classInfosFromClasses: ClassInfoDto[] = filteredClassesForSchoolYear.map( - (classWithSchoolYear): ClassInfoDto => { - const teachers: UserDO[] = []; - - const mapped: ClassInfoDto = GroupUcMapper.mapClassToClassInfoDto( - classWithSchoolYear.clazz, - teachers, - classWithSchoolYear.schoolYear - ); - - return mapped; - } - ); - return classInfosFromClasses; - } - - private async findGroupsForSchool(schoolId: EntityId): Promise { - const filter: IGroupFilter = { schoolId }; - - const groups: Page = await this.groupService.findGroups(filter); - - const classInfosFromGroups: ClassInfoDto[] = await this.getClassInfosFromGroups(groups.data); - - return classInfosFromGroups; - } - - private async findGroupsForUser(userId: EntityId): Promise { - const filter: IGroupFilter = { userId }; - - const groups: Page = await this.groupService.findGroups(filter); - - const classInfosFromGroups: ClassInfoDto[] = await this.getClassInfosFromGroups(groups.data); - - return classInfosFromGroups; - } + public async getGroup(userId: EntityId, groupId: EntityId): Promise { + const group: Group = await this.groupService.findById(groupId); - private async getClassInfosFromGroups(groups: Group[]): Promise { - const systemMap: Map = await this.findSystemNamesForGroups(groups); + await this.checkPermission(userId, group); - const classInfosFromGroups: ClassInfoDto[] = await Promise.all( - groups.map(async (group: Group): Promise => this.getClassInfoFromGroup(group, systemMap)) - ); + const resolvedUsers: ResolvedGroupUser[] = await this.findUsersForGroup(group); + const resolvedGroup: ResolvedGroupDto = GroupUcMapper.mapToResolvedGroupDto(group, resolvedUsers); - return classInfosFromGroups; + return resolvedGroup; } - private async getClassInfoFromGroup(group: Group, systemMap: Map): Promise { - let system: SystemDto | undefined; - if (group.externalSource) { - system = systemMap.get(group.externalSource.systemId); - } - - const resolvedUsers: ResolvedGroupUser[] = []; - - let synchronizedCourses: Course[] = []; - if (this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { - synchronizedCourses = await this.courseService.findBySyncedGroup(group); - } - - const mapped: ClassInfoDto = GroupUcMapper.mapGroupToClassInfoDto( + private async checkPermission(userId: EntityId, group: Group): Promise { + const user: User = await this.authorizationService.getUserWithPermissions(userId); + return this.authorizationService.checkPermission( + user, group, - resolvedUsers, - synchronizedCourses, - system - ); - - return mapped; - } - - private async findSystemNamesForGroups(groups: Group[]): Promise> { - const systemIds: EntityId[] = groups - .map((group: Group): string | undefined => group.externalSource?.systemId) - .filter((systemId: string | undefined): systemId is EntityId => systemId !== undefined); - - const uniqueSystemIds: EntityId[] = Array.from(new Set(systemIds)); - - const systems: Map = new Map(); - - await Promise.all( - uniqueSystemIds.map(async (systemId: string): Promise => { - const system: SystemDto = await this.systemService.findById(systemId); - - systems.set(systemId, system); - }) + AuthorizationContextBuilder.read([Permission.GROUP_VIEW]) ); - - return systems; } private async findUsersForGroup(group: Group): Promise { @@ -327,39 +78,6 @@ export class GroupUc { return resolvedGroupUsers; } - private applyPagination(combinedClassInfo: ClassInfoDto[], skip: number, limit: number | undefined): ClassInfoDto[] { - let page: ClassInfoDto[]; - - if (limit === -1) { - page = combinedClassInfo.slice(skip); - } else { - page = combinedClassInfo.slice(skip, limit ? skip + limit : combinedClassInfo.length); - } - - return page; - } - - // ----------------------------------------------------------------------------------------------------------------- - public async getGroup(userId: EntityId, groupId: EntityId): Promise { - const group: Group = await this.groupService.findById(groupId); - - await this.checkPermission(userId, group); - - const resolvedUsers: ResolvedGroupUser[] = await this.findUsersForGroup(group); - const resolvedGroup: ResolvedGroupDto = GroupUcMapper.mapToResolvedGroupDto(group, resolvedUsers); - - return resolvedGroup; - } - - private async checkPermission(userId: EntityId, group: Group): Promise { - const user: User = await this.authorizationService.getUserWithPermissions(userId); - return this.authorizationService.checkPermission( - user, - group, - AuthorizationContextBuilder.read([Permission.GROUP_VIEW]) - ); - } - public async getAllGroups( userId: EntityId, schoolId: EntityId, diff --git a/apps/server/src/modules/group/uc/index.ts b/apps/server/src/modules/group/uc/index.ts index 3f268fdf74c..da20bd0257e 100644 --- a/apps/server/src/modules/group/uc/index.ts +++ b/apps/server/src/modules/group/uc/index.ts @@ -1 +1,2 @@ -export * from './group.uc'; +export { GroupUc } from './group.uc'; +export { ClassGroupUc } from './class-group.uc'; From 37d78d9185edb7f4e775981c04b89980a86ed50e Mon Sep 17 00:00:00 2001 From: Igor Richter Date: Wed, 8 May 2024 11:56:04 +0200 Subject: [PATCH 07/11] - splitting groupUc --- .../group/controller/group.controller.ts | 6 +- .../modules/group/uc/class-group.uc.spec.ts | 1002 +++++++++++++++++ .../src/modules/group/uc/class-group.uc.ts | 303 +++++ .../src/modules/group/uc/group.uc.spec.ts | 923 +-------------- apps/server/src/modules/group/uc/group.uc.ts | 310 +---- apps/server/src/modules/group/uc/index.ts | 3 +- 6 files changed, 1329 insertions(+), 1218 deletions(-) create mode 100644 apps/server/src/modules/group/uc/class-group.uc.spec.ts create mode 100644 apps/server/src/modules/group/uc/class-group.uc.ts diff --git a/apps/server/src/modules/group/controller/group.controller.ts b/apps/server/src/modules/group/controller/group.controller.ts index 68333286746..f8bdae2ad28 100644 --- a/apps/server/src/modules/group/controller/group.controller.ts +++ b/apps/server/src/modules/group/controller/group.controller.ts @@ -6,7 +6,7 @@ import { ApiValidationError } from '@shared/common'; import { Page } from '@shared/domain/domainobject'; import { IFindOptions } from '@shared/domain/interface'; import { ErrorResponse } from '@src/core/error/dto'; -import { GroupUc } from '../uc'; +import { ClassGroupUc, GroupUc } from '../uc'; import { ClassInfoDto, ResolvedGroupDto } from '../uc/dto'; import { ClassCallerParams, @@ -25,7 +25,7 @@ import { GroupResponseMapper } from './mapper'; @Authenticate('jwt') @Controller('groups') export class GroupController { - constructor(private readonly groupUc: GroupUc) {} + constructor(private readonly groupUc: GroupUc, private readonly classGroupUc: ClassGroupUc) {} @ApiOperation({ summary: 'Get a list of classes and groups of type class for the current user.' }) @ApiResponse({ status: HttpStatus.OK, type: ClassInfoSearchListResponse }) @@ -39,7 +39,7 @@ export class GroupController { @Query() callerParams: ClassCallerParams, @CurrentUser() currentUser: ICurrentUser ): Promise { - const board: Page = await this.groupUc.findAllClasses( + const board: Page = await this.classGroupUc.findAllClasses( currentUser.userId, currentUser.schoolId, filterParams.type, diff --git a/apps/server/src/modules/group/uc/class-group.uc.spec.ts b/apps/server/src/modules/group/uc/class-group.uc.spec.ts new file mode 100644 index 00000000000..7fc75d46042 --- /dev/null +++ b/apps/server/src/modules/group/uc/class-group.uc.spec.ts @@ -0,0 +1,1002 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Action, AuthorizationContext, AuthorizationService } from '@modules/authorization'; +import { ClassService } from '@modules/class'; +import { Class } from '@modules/class/domain'; +import { classFactory } from '@modules/class/domain/testing/factory/class.factory'; +import { ClassGroupUc } from '@modules/group/uc/class-group.uc'; +import { Course } from '@modules/learnroom/domain'; +import { CourseDoService } from '@modules/learnroom/service/course-do.service'; +import { courseFactory } from '@modules/learnroom/testing'; +import { SchoolYearService } from '@modules/legacy-school'; +import { ProvisioningConfig } from '@modules/provisioning'; +import { RoleService } from '@modules/role'; +import { RoleDto } from '@modules/role/service/dto/role.dto'; +import { School, SchoolService } from '@modules/school/domain'; +import { schoolFactory } from '@modules/school/testing'; +import { LegacySystemService, SystemDto } from '@modules/system'; +import { UserService } from '@modules/user'; +import { ForbiddenException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Page, UserDO } from '@shared/domain/domainobject'; +import { SchoolYearEntity, User } from '@shared/domain/entity'; +import { Permission, SortOrder } from '@shared/domain/interface'; +import { + groupFactory, + roleDtoFactory, + schoolYearFactory, + setupEntities, + UserAndAccountTestFactory, + userDoFactory, + userFactory, +} from '@shared/testing'; +import { ClassRequestContext, SchoolYearQueryType } from '../controller/dto/interface'; +import { Group, IGroupFilter } from '../domain'; +import { UnknownQueryTypeLoggableException } from '../loggable'; +import { GroupService } from '../service'; +import { ClassInfoDto } from './dto'; +import { ClassRootType } from './dto/class-root-type'; + +describe('ClassGroupUc', () => { + let module: TestingModule; + let uc: ClassGroupUc; + + let groupService: DeepMocked; + let userService: DeepMocked; + let roleService: DeepMocked; + let classService: DeepMocked; + let systemService: DeepMocked; + let schoolService: DeepMocked; + let authorizationService: DeepMocked; + let schoolYearService: DeepMocked; + let courseService: DeepMocked; + let configService: DeepMocked>; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + ClassGroupUc, + { + provide: GroupService, + useValue: createMock(), + }, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: RoleService, + useValue: createMock(), + }, + { + provide: ClassService, + useValue: createMock(), + }, + { + provide: LegacySystemService, + useValue: createMock(), + }, + { + provide: SchoolService, + useValue: createMock(), + }, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: SchoolYearService, + useValue: createMock(), + }, + { + provide: CourseDoService, + useValue: createMock(), + }, + { + provide: ConfigService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(ClassGroupUc); + groupService = module.get(GroupService); + userService = module.get(UserService); + roleService = module.get(RoleService); + classService = module.get(ClassService); + systemService = module.get(LegacySystemService); + schoolService = module.get(SchoolService); + authorizationService = module.get(AuthorizationService); + schoolYearService = module.get(SchoolYearService); + courseService = module.get(CourseDoService); + configService = module.get(ConfigService); + + await setupEntities(); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('findAllClasses', () => { + describe('when the user has no permission', () => { + const setup = () => { + const school: School = schoolFactory.build(); + const user: User = userFactory.buildWithId(); + const error = new ForbiddenException(); + + schoolService.getSchoolById.mockResolvedValue(school); + authorizationService.getUserWithPermissions.mockResolvedValue(user); + authorizationService.checkPermission.mockImplementation(() => { + throw error; + }); + authorizationService.hasAllPermissions.mockReturnValueOnce(false); + + return { + user, + error, + }; + }; + + it('should throw forbidden', async () => { + const { user, error } = setup(); + + const func = () => uc.findAllClasses(user.id, user.school.id); + + await expect(func).rejects.toThrow(error); + }); + }); + + describe('when accessing as a normal user', () => { + const setup = () => { + const school: School = schoolFactory.build({ permissions: { teacher: { STUDENT_LIST: true } } }); + const { studentUser } = UserAndAccountTestFactory.buildStudent(); + const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const teacherRole: RoleDto = roleDtoFactory.buildWithId({ + id: teacherUser.roles[0].id, + name: teacherUser.roles[0].name, + }); + const studentRole: RoleDto = roleDtoFactory.buildWithId({ + id: studentUser.roles[0].id, + name: studentUser.roles[0].name, + }); + const teacherUserDo: UserDO = userDoFactory.buildWithId({ + id: teacherUser.id, + lastName: teacherUser.lastName, + roles: [{ id: teacherUser.roles[0].id, name: teacherUser.roles[0].name }], + }); + const studentUserDo: UserDO = userDoFactory.buildWithId({ + id: studentUser.id, + lastName: studentUser.lastName, + roles: [{ id: studentUser.roles[0].id, name: studentUser.roles[0].name }], + }); + const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); + const nextSchoolYear: SchoolYearEntity = schoolYearFactory.buildWithId({ + startDate: schoolYear.endDate, + }); + const clazz: Class = classFactory.build({ + name: 'A', + teacherIds: [teacherUser.id], + source: 'LDAP', + year: schoolYear.id, + }); + const successorClass: Class = classFactory.build({ + name: 'NEW', + teacherIds: [teacherUser.id], + year: nextSchoolYear.id, + }); + const classWithoutSchoolYear = classFactory.build({ + name: 'NoYear', + teacherIds: [teacherUser.id], + year: undefined, + }); + + const system: SystemDto = new SystemDto({ + id: new ObjectId().toHexString(), + displayName: 'External System', + type: 'oauth2', + }); + const group: Group = groupFactory.build({ + name: 'B', + users: [{ userId: teacherUser.id, roleId: teacherUser.roles[0].id }], + externalSource: undefined, + }); + const groupWithSystem: Group = groupFactory.build({ + name: 'C', + externalSource: { externalId: 'externalId', systemId: system.id }, + users: [ + { userId: teacherUser.id, roleId: teacherUser.roles[0].id }, + { userId: studentUser.id, roleId: studentUser.roles[0].id }, + ], + }); + const synchronizedCourse: Course = courseFactory.build({ syncedWithGroup: group.id }); + + schoolService.getSchoolById.mockResolvedValueOnce(school); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(teacherUser); + authorizationService.hasAllPermissions.mockReturnValueOnce(false); + classService.findAllByUserId.mockResolvedValueOnce([clazz, successorClass, classWithoutSchoolYear]); + groupService.findGroups.mockResolvedValueOnce(new Page([group, groupWithSystem], 2)); + classService.findClassesForSchool.mockResolvedValueOnce([clazz, successorClass, classWithoutSchoolYear]); + groupService.findGroups.mockResolvedValueOnce(new Page([group, groupWithSystem], 2)); + systemService.findById.mockResolvedValue(system); + userService.findById.mockImplementation((userId: string): Promise => { + if (userId === teacherUser.id) { + return Promise.resolve(teacherUserDo); + } + + if (userId === studentUser.id) { + return Promise.resolve(studentUserDo); + } + + throw new Error(); + }); + userService.findByIdOrNull.mockImplementation((userId: string): Promise => { + if (userId === teacherUser.id) { + return Promise.resolve(teacherUserDo); + } + + if (userId === studentUser.id) { + return Promise.resolve(studentUserDo); + } + + throw new Error(); + }); + roleService.findById.mockImplementation((roleId: string): Promise => { + if (roleId === teacherUser.roles[0].id) { + return Promise.resolve(teacherRole); + } + + if (roleId === studentUser.roles[0].id) { + return Promise.resolve(studentRole); + } + + throw new Error(); + }); + schoolYearService.findById.mockResolvedValueOnce(schoolYear); + schoolYearService.findById.mockResolvedValueOnce(nextSchoolYear); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + configService.get.mockReturnValueOnce(true); + courseService.findBySyncedGroup.mockResolvedValueOnce([synchronizedCourse]); + courseService.findBySyncedGroup.mockResolvedValueOnce([]); + + return { + teacherUser, + school, + clazz, + successorClass, + classWithoutSchoolYear, + group, + groupWithSystem, + system, + schoolYear, + nextSchoolYear, + synchronizedCourse, + }; + }; + + it('should check the required permissions', async () => { + const { teacherUser, school } = setup(); + + await uc.findAllClasses(teacherUser.id, teacherUser.school.id, SchoolYearQueryType.CURRENT_YEAR); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith<[User, School, AuthorizationContext]>( + teacherUser, + school, + { + action: Action.read, + requiredPermissions: [Permission.CLASS_VIEW, Permission.GROUP_VIEW], + } + ); + }); + + it('should check the access to the full list', async () => { + const { teacherUser } = setup(); + + await uc.findAllClasses(teacherUser.id, teacherUser.school.id); + + expect(authorizationService.hasAllPermissions).toHaveBeenCalledWith<[User, string[]]>(teacherUser, [ + Permission.CLASS_FULL_ADMIN, + Permission.GROUP_FULL_ADMIN, + ]); + }); + + describe('when accessing form course as a teacher', () => { + it('should call findClassesForSchool method from classService', async () => { + const { teacherUser } = setup(); + + await uc.findAllClasses(teacherUser.id, teacherUser.school.id, undefined, ClassRequestContext.COURSE); + + expect(classService.findClassesForSchool).toHaveBeenCalled(); + }); + }); + + describe('when accessing form class overview as a teacher', () => { + it('should call findAllByUserId method from classService', async () => { + const { teacherUser } = setup(); + + await uc.findAllClasses(teacherUser.id, teacherUser.school.id, undefined, ClassRequestContext.CLASS_OVERVIEW); + + expect(classService.findAllByUserId).toHaveBeenCalled(); + }); + }); + + describe('when no pagination is given', () => { + it('should return all classes sorted by name', async () => { + const { + teacherUser, + clazz, + successorClass, + classWithoutSchoolYear, + group, + groupWithSystem, + system, + schoolYear, + nextSchoolYear, + synchronizedCourse, + } = setup(); + + const result: Page = await uc.findAllClasses(teacherUser.id, teacherUser.school.id, undefined); + + expect(result).toEqual>({ + data: [ + { + id: clazz.id, + name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, + type: ClassRootType.CLASS, + externalSourceName: clazz.source, + teacherNames: [], + schoolYear: schoolYear.name, + isUpgradable: false, + studentCount: 2, + }, + { + id: successorClass.id, + name: successorClass.gradeLevel + ? `${successorClass.gradeLevel}${successorClass.name}` + : successorClass.name, + type: ClassRootType.CLASS, + externalSourceName: successorClass.source, + teacherNames: [], + schoolYear: nextSchoolYear.name, + isUpgradable: false, + studentCount: 2, + }, + { + id: classWithoutSchoolYear.id, + name: classWithoutSchoolYear.gradeLevel + ? `${classWithoutSchoolYear.gradeLevel}${classWithoutSchoolYear.name}` + : classWithoutSchoolYear.name, + type: ClassRootType.CLASS, + externalSourceName: classWithoutSchoolYear.source, + teacherNames: [], + isUpgradable: false, + studentCount: 2, + }, + { + id: group.id, + name: group.name, + type: ClassRootType.GROUP, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [{ id: synchronizedCourse.id, name: synchronizedCourse.name }], + }, + { + id: groupWithSystem.id, + name: groupWithSystem.name, + type: ClassRootType.GROUP, + externalSourceName: system.displayName, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [], + }, + ], + total: 5, + }); + }); + + it('should call group service with userId and no pagination', async () => { + const { teacherUser } = setup(); + + await uc.findAllClasses(teacherUser.id, teacherUser.school.id); + + expect(groupService.findGroups).toHaveBeenCalledWith<[IGroupFilter]>({ userId: teacherUser.id }); + }); + }); + + describe('when sorting by external source name in descending order', () => { + it('should return all classes sorted by external source name in descending order', async () => { + const { + teacherUser, + clazz, + classWithoutSchoolYear, + group, + groupWithSystem, + system, + schoolYear, + synchronizedCourse, + } = setup(); + + const result: Page = await uc.findAllClasses( + teacherUser.id, + teacherUser.school.id, + SchoolYearQueryType.CURRENT_YEAR, + undefined, + undefined, + undefined, + 'externalSourceName', + SortOrder.desc + ); + + expect(result).toEqual>({ + data: [ + { + id: classWithoutSchoolYear.id, + name: classWithoutSchoolYear.gradeLevel + ? `${classWithoutSchoolYear.gradeLevel}${classWithoutSchoolYear.name}` + : classWithoutSchoolYear.name, + type: ClassRootType.CLASS, + externalSourceName: classWithoutSchoolYear.source, + teacherNames: [], + isUpgradable: false, + studentCount: 2, + }, + { + id: clazz.id, + name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, + type: ClassRootType.CLASS, + externalSourceName: clazz.source, + teacherNames: [], + schoolYear: schoolYear.name, + isUpgradable: false, + studentCount: 2, + }, + { + id: groupWithSystem.id, + name: groupWithSystem.name, + type: ClassRootType.GROUP, + externalSourceName: system.displayName, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [], + }, + { + id: group.id, + name: group.name, + type: ClassRootType.GROUP, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [{ id: synchronizedCourse.id, name: synchronizedCourse.name }], + }, + ], + total: 4, + }); + }); + }); + + describe('when using pagination', () => { + it('should return the selected page', async () => { + const { teacherUser, group, synchronizedCourse } = setup(); + + const result: Page = await uc.findAllClasses( + teacherUser.id, + teacherUser.school.id, + SchoolYearQueryType.CURRENT_YEAR, + undefined, + 2, + 1, + 'name', + SortOrder.asc + ); + + expect(result).toEqual>({ + data: [ + { + id: group.id, + name: group.name, + type: ClassRootType.GROUP, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [{ id: synchronizedCourse.id, name: synchronizedCourse.name }], + }, + ], + total: 4, + }); + }); + }); + + describe('when querying for classes from next school year', () => { + it('should only return classes from next school year', async () => { + const { teacherUser, successorClass, nextSchoolYear } = setup(); + + const result: Page = await uc.findAllClasses( + teacherUser.id, + teacherUser.school.id, + SchoolYearQueryType.NEXT_YEAR + ); + + expect(result).toEqual>({ + data: [ + { + id: successorClass.id, + name: successorClass.gradeLevel + ? `${successorClass.gradeLevel}${successorClass.name}` + : successorClass.name, + externalSourceName: successorClass.source, + type: ClassRootType.CLASS, + teacherNames: [], + schoolYear: nextSchoolYear.name, + isUpgradable: false, + studentCount: 2, + }, + ], + total: 1, + }); + }); + }); + + describe('when querying for archived classes', () => { + it('should only return classes from previous school years', async () => { + const { teacherUser } = setup(); + + const result: Page = await uc.findAllClasses( + teacherUser.id, + teacherUser.school.id, + SchoolYearQueryType.PREVIOUS_YEARS + ); + + expect(result).toEqual>({ + data: [], + total: 0, + }); + }); + }); + + describe('when querying for not existing type', () => { + it('should throw', async () => { + const { teacherUser } = setup(); + + const func = async () => + uc.findAllClasses(teacherUser.id, teacherUser.school.id, 'notAType' as SchoolYearQueryType); + + await expect(func).rejects.toThrow(UnknownQueryTypeLoggableException); + }); + }); + }); + + describe('when accessing as a user with elevated permission', () => { + const setup = (generateClasses = false) => { + const school: School = schoolFactory.build(); + const { studentUser } = UserAndAccountTestFactory.buildStudent(); + const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const { adminUser } = UserAndAccountTestFactory.buildAdmin(); + const teacherRole: RoleDto = roleDtoFactory.buildWithId({ + id: teacherUser.roles[0].id, + name: teacherUser.roles[0].name, + }); + const studentRole: RoleDto = roleDtoFactory.buildWithId({ + id: studentUser.roles[0].id, + name: studentUser.roles[0].name, + }); + const adminUserDo: UserDO = userDoFactory.buildWithId({ + id: adminUser.id, + lastName: adminUser.lastName, + roles: [{ id: adminUser.roles[0].id, name: adminUser.roles[0].name }], + }); + const teacherUserDo: UserDO = userDoFactory.buildWithId({ + id: teacherUser.id, + lastName: teacherUser.lastName, + roles: [{ id: teacherUser.roles[0].id, name: teacherUser.roles[0].name }], + }); + const studentUserDo: UserDO = userDoFactory.buildWithId({ + id: studentUser.id, + lastName: studentUser.lastName, + roles: [{ id: studentUser.roles[0].id, name: studentUser.roles[0].name }], + }); + const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); + let clazzes: Class[] = []; + if (generateClasses) { + clazzes = classFactory.buildList(11, { + name: 'A', + teacherIds: [teacherUser.id], + source: 'LDAP', + year: schoolYear.id, + }); + } + const clazz: Class = classFactory.build({ + name: 'A', + teacherIds: [teacherUser.id], + source: 'LDAP', + year: schoolYear.id, + }); + const system: SystemDto = new SystemDto({ + id: new ObjectId().toHexString(), + displayName: 'External System', + type: 'oauth2', + }); + const group: Group = groupFactory.build({ + name: 'B', + users: [{ userId: teacherUser.id, roleId: teacherUser.roles[0].id }], + externalSource: undefined, + }); + const groupWithSystem: Group = groupFactory.build({ + name: 'C', + externalSource: { externalId: 'externalId', systemId: system.id }, + users: [ + { userId: teacherUser.id, roleId: teacherUser.roles[0].id }, + { userId: studentUser.id, roleId: studentUser.roles[0].id }, + ], + }); + + schoolService.getSchoolById.mockResolvedValueOnce(school); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(adminUser); + authorizationService.hasAllPermissions.mockReturnValueOnce(true); + classService.findClassesForSchool.mockResolvedValueOnce([...clazzes, clazz]); + groupService.findGroups.mockResolvedValueOnce(new Page([group, groupWithSystem], 2)); + systemService.findById.mockResolvedValue(system); + + userService.findById.mockImplementation((userId: string): Promise => { + if (userId === teacherUser.id) { + return Promise.resolve(teacherUserDo); + } + + if (userId === studentUser.id) { + return Promise.resolve(studentUserDo); + } + + if (userId === adminUser.id) { + return Promise.resolve(adminUserDo); + } + + throw new Error(); + }); + userService.findByIdOrNull.mockImplementation((userId: string): Promise => { + if (userId === teacherUser.id) { + return Promise.resolve(teacherUserDo); + } + + if (userId === studentUser.id) { + return Promise.resolve(studentUserDo); + } + + if (userId === adminUser.id) { + return Promise.resolve(adminUserDo); + } + + throw new Error(); + }); + roleService.findById.mockImplementation((roleId: string): Promise => { + if (roleId === teacherUser.roles[0].id) { + return Promise.resolve(teacherRole); + } + + if (roleId === studentUser.roles[0].id) { + return Promise.resolve(studentRole); + } + + throw new Error(); + }); + schoolYearService.findById.mockResolvedValue(schoolYear); + configService.get.mockReturnValueOnce(true); + courseService.findBySyncedGroup.mockResolvedValueOnce([]); + courseService.findBySyncedGroup.mockResolvedValueOnce([]); + + return { + adminUser, + teacherUser, + school, + clazz, + group, + groupWithSystem, + system, + schoolYear, + }; + }; + + it('should check the required permissions', async () => { + const { adminUser, school } = setup(); + + await uc.findAllClasses(adminUser.id, adminUser.school.id); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith<[User, School, AuthorizationContext]>( + adminUser, + school, + { + action: Action.read, + requiredPermissions: [Permission.CLASS_VIEW, Permission.GROUP_VIEW], + } + ); + }); + + it('should check the access to the full list', async () => { + const { adminUser } = setup(); + + await uc.findAllClasses(adminUser.id, adminUser.school.id); + + expect(authorizationService.hasAllPermissions).toHaveBeenCalledWith<[User, string[]]>(adminUser, [ + Permission.CLASS_FULL_ADMIN, + Permission.GROUP_FULL_ADMIN, + ]); + }); + + describe('when no pagination is given', () => { + it('should return all classes sorted by name', async () => { + const { adminUser, clazz, group, groupWithSystem, system, schoolYear } = setup(); + + const result: Page = await uc.findAllClasses(adminUser.id, adminUser.school.id); + + expect(result).toEqual>({ + data: [ + { + id: clazz.id, + name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, + type: ClassRootType.CLASS, + externalSourceName: clazz.source, + teacherNames: [], + schoolYear: schoolYear.name, + isUpgradable: false, + studentCount: 2, + }, + { + id: group.id, + name: group.name, + type: ClassRootType.GROUP, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [], + }, + { + id: groupWithSystem.id, + name: groupWithSystem.name, + type: ClassRootType.GROUP, + externalSourceName: system.displayName, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [], + }, + ], + total: 3, + }); + }); + + it('should call group service with schoolId and no pagination', async () => { + const { teacherUser } = setup(); + + await uc.findAllClasses(teacherUser.id, teacherUser.school.id); + + expect(groupService.findGroups).toHaveBeenCalledWith<[IGroupFilter]>({ schoolId: teacherUser.school.id }); + }); + }); + + describe('when sorting by external source name in descending order', () => { + it('should return all classes sorted by external source name in descending order', async () => { + const { adminUser, clazz, group, groupWithSystem, system, schoolYear } = setup(); + + const result: Page = await uc.findAllClasses( + adminUser.id, + adminUser.school.id, + undefined, + undefined, + undefined, + undefined, + 'externalSourceName', + SortOrder.desc + ); + + expect(result).toEqual>({ + data: [ + { + id: clazz.id, + name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, + type: ClassRootType.CLASS, + externalSourceName: clazz.source, + teacherNames: [], + schoolYear: schoolYear.name, + isUpgradable: false, + studentCount: 2, + }, + { + id: groupWithSystem.id, + name: groupWithSystem.name, + type: ClassRootType.GROUP, + externalSourceName: system.displayName, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [], + }, + { + id: group.id, + name: group.name, + type: ClassRootType.GROUP, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [], + }, + ], + total: 3, + }); + }); + }); + + describe('when using pagination', () => { + it('should return the selected page', async () => { + const { adminUser, group } = setup(); + + const result: Page = await uc.findAllClasses( + adminUser.id, + adminUser.school.id, + undefined, + undefined, + 1, + 1, + 'name', + SortOrder.asc + ); + + expect(result).toEqual>({ + data: [ + { + id: group.id, + name: group.name, + type: ClassRootType.GROUP, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [], + }, + ], + total: 3, + }); + }); + + it('should return classes with expected limit', async () => { + const { adminUser } = setup(true); + + const result: Page = await uc.findAllClasses( + adminUser.id, + adminUser.school.id, + undefined, + undefined, + 0, + 5 + ); + + expect(result.data.length).toEqual(5); + }); + + it('should return all classes without limit', async () => { + const { adminUser } = setup(true); + + const result: Page = await uc.findAllClasses( + adminUser.id, + adminUser.school.id, + undefined, + undefined, + 0, + -1 + ); + + expect(result.data.length).toEqual(14); + }); + }); + }); + + describe('when class has a user referenced which is not existing', () => { + const setup = () => { + const school: School = schoolFactory.build(); + const notFoundReferenceId = new ObjectId().toHexString(); + const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + const teacherRole: RoleDto = roleDtoFactory.buildWithId({ + id: teacherUser.roles[0].id, + name: teacherUser.roles[0].name, + }); + + const teacherUserDo: UserDO = userDoFactory.buildWithId({ + id: teacherUser.id, + lastName: teacherUser.lastName, + roles: [{ id: teacherUser.roles[0].id, name: teacherUser.roles[0].name }], + }); + + const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); + const clazz: Class = classFactory.build({ + name: 'A', + teacherIds: [teacherUser.id, notFoundReferenceId], + source: 'LDAP', + year: schoolYear.id, + }); + const system: SystemDto = new SystemDto({ + id: new ObjectId().toHexString(), + displayName: 'External System', + type: 'oauth2', + }); + const group: Group = groupFactory.build({ + name: 'B', + users: [ + { userId: teacherUser.id, roleId: teacherUser.roles[0].id }, + { userId: notFoundReferenceId, roleId: teacherUser.roles[0].id }, + ], + externalSource: undefined, + }); + + schoolService.getSchoolById.mockResolvedValueOnce(school); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(teacherUser); + authorizationService.hasAllPermissions.mockReturnValueOnce(false); + classService.findAllByUserId.mockResolvedValueOnce([clazz]); + groupService.findGroups.mockResolvedValueOnce(new Page([group], 1)); + systemService.findById.mockResolvedValue(system); + + userService.findById.mockImplementation((userId: string): Promise => { + if (userId === teacherUser.id) { + return Promise.resolve(teacherUserDo); + } + + throw new Error(); + }); + userService.findByIdOrNull.mockImplementation((userId: string): Promise => { + if (userId === teacherUser.id) { + return Promise.resolve(teacherUserDo); + } + + if (userId === notFoundReferenceId) { + return Promise.resolve(null); + } + + throw new Error(); + }); + roleService.findById.mockImplementation((roleId: string): Promise => { + if (roleId === teacherUser.roles[0].id) { + return Promise.resolve(teacherRole); + } + + throw new Error(); + }); + schoolYearService.findById.mockResolvedValue(schoolYear); + configService.get.mockReturnValueOnce(true); + courseService.findBySyncedGroup.mockResolvedValueOnce([]); + + return { + teacherUser, + clazz, + group, + notFoundReferenceId, + schoolYear, + }; + }; + + it('should return class without missing user', async () => { + const { teacherUser, clazz, group, schoolYear } = setup(); + + const result = await uc.findAllClasses(teacherUser.id, teacherUser.school.id); + + expect(result).toEqual>({ + data: [ + { + id: clazz.id, + name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, + type: ClassRootType.CLASS, + externalSourceName: clazz.source, + teacherNames: [], + schoolYear: schoolYear.name, + isUpgradable: false, + studentCount: 2, + }, + { + id: group.id, + name: group.name, + type: ClassRootType.GROUP, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [], + }, + ], + total: 2, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/group/uc/class-group.uc.ts b/apps/server/src/modules/group/uc/class-group.uc.ts new file mode 100644 index 00000000000..e81df5df037 --- /dev/null +++ b/apps/server/src/modules/group/uc/class-group.uc.ts @@ -0,0 +1,303 @@ +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { ClassService } from '@modules/class'; +import { Class } from '@modules/class/domain'; +import { Course } from '@modules/learnroom/domain'; +import { CourseDoService } from '@modules/learnroom/service/course-do.service'; +import { SchoolYearService } from '@modules/legacy-school'; +import { ProvisioningConfig } from '@modules/provisioning'; +import { School, SchoolService } from '@modules/school/domain'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { SortHelper } from '@shared/common'; +import { Page, UserDO } from '@shared/domain/domainobject'; +import { SchoolYearEntity, User } from '@shared/domain/entity'; +import { Permission, SortOrder } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; +import { LegacySystemService, SystemDto } from '@src/modules/system'; +import { ClassRequestContext, SchoolYearQueryType } from '../controller/dto/interface'; +import { Group, IGroupFilter } from '../domain'; +import { UnknownQueryTypeLoggableException } from '../loggable'; +import { GroupService } from '../service'; +import { ClassInfoDto, ResolvedGroupUser } from './dto'; +import { GroupUcMapper } from './mapper/group-uc.mapper'; + +@Injectable() +export class ClassGroupUc { + constructor( + private readonly groupService: GroupService, + private readonly classService: ClassService, + private readonly systemService: LegacySystemService, + private readonly schoolService: SchoolService, + private readonly authorizationService: AuthorizationService, + private readonly schoolYearService: SchoolYearService, + private readonly courseService: CourseDoService, + private readonly configService: ConfigService + ) {} + + public async findAllClasses( + userId: EntityId, + schoolId: EntityId, + schoolYearQueryType?: SchoolYearQueryType, + calledFrom?: ClassRequestContext, + skip = 0, + limit?: number, + sortBy: keyof ClassInfoDto = 'name', + sortOrder: SortOrder = SortOrder.asc + ): Promise> { + const school: School = await this.schoolService.getSchoolById(schoolId); + + const user: User = await this.authorizationService.getUserWithPermissions(userId); + this.authorizationService.checkPermission( + user, + school, + AuthorizationContextBuilder.read([Permission.CLASS_VIEW, Permission.GROUP_VIEW]) + ); + + const canSeeFullList: boolean = this.authorizationService.hasAllPermissions(user, [ + Permission.CLASS_FULL_ADMIN, + Permission.GROUP_FULL_ADMIN, + ]); + + const calledFromCourse: boolean = + calledFrom === ClassRequestContext.COURSE && school.getPermissions()?.teacher?.STUDENT_LIST === true; + + let combinedClassInfo: ClassInfoDto[]; + if (canSeeFullList || calledFromCourse) { + combinedClassInfo = await this.findCombinedClassListForSchool(schoolId, schoolYearQueryType); + } else { + combinedClassInfo = await this.findCombinedClassListForUser(userId, schoolYearQueryType); + } + + combinedClassInfo.sort((a: ClassInfoDto, b: ClassInfoDto): number => + SortHelper.genericSortFunction(a[sortBy], b[sortBy], sortOrder) + ); + + const pageContent: ClassInfoDto[] = this.applyPagination(combinedClassInfo, skip, limit); + + const page: Page = new Page(pageContent, combinedClassInfo.length); + + return page; + } + + private async findCombinedClassListForSchool( + schoolId: EntityId, + schoolYearQueryType?: SchoolYearQueryType + ): Promise { + let classInfosFromGroups: ClassInfoDto[] = []; + + const classInfosFromClasses = await this.findClassesForSchool(schoolId, schoolYearQueryType); + + if (!schoolYearQueryType || schoolYearQueryType === SchoolYearQueryType.CURRENT_YEAR) { + classInfosFromGroups = await this.findGroupsForSchool(schoolId); + } + + const combinedClassInfo: ClassInfoDto[] = [...classInfosFromClasses, ...classInfosFromGroups]; + + return combinedClassInfo; + } + + private async findCombinedClassListForUser( + userId: EntityId, + schoolYearQueryType?: SchoolYearQueryType + ): Promise { + let classInfosFromGroups: ClassInfoDto[] = []; + + const classInfosFromClasses = await this.findClassesForUser(userId, schoolYearQueryType); + + if (!schoolYearQueryType || schoolYearQueryType === SchoolYearQueryType.CURRENT_YEAR) { + classInfosFromGroups = await this.findGroupsForUser(userId); + } + + const combinedClassInfo: ClassInfoDto[] = [...classInfosFromClasses, ...classInfosFromGroups]; + + return combinedClassInfo; + } + + private async findClassesForSchool( + schoolId: EntityId, + schoolYearQueryType?: SchoolYearQueryType + ): Promise { + const classes: Class[] = await this.classService.findClassesForSchool(schoolId); + + const classInfosFromClasses: ClassInfoDto[] = await this.getClassInfosFromClasses(classes, schoolYearQueryType); + + return classInfosFromClasses; + } + + private async findClassesForUser( + userId: EntityId, + schoolYearQueryType?: SchoolYearQueryType + ): Promise { + const classes: Class[] = await this.classService.findAllByUserId(userId); + + const classInfosFromClasses: ClassInfoDto[] = await this.getClassInfosFromClasses(classes, schoolYearQueryType); + + return classInfosFromClasses; + } + + private async getClassInfosFromClasses( + classes: Class[], + schoolYearQueryType?: SchoolYearQueryType + ): Promise { + const currentYear: SchoolYearEntity = await this.schoolYearService.getCurrentSchoolYear(); + + const classesWithSchoolYear: { clazz: Class; schoolYear?: SchoolYearEntity }[] = await this.addSchoolYearsToClasses( + classes + ); + + const filteredClassesForSchoolYear = classesWithSchoolYear.filter((classWithSchoolYear) => + this.isClassOfQueryType(currentYear, classWithSchoolYear.schoolYear, schoolYearQueryType) + ); + + const classInfosFromClasses: ClassInfoDto[] = this.mapClassInfosFromClasses(filteredClassesForSchoolYear); + + return classInfosFromClasses; + } + + private async addSchoolYearsToClasses(classes: Class[]): Promise<{ clazz: Class; schoolYear?: SchoolYearEntity }[]> { + const classesWithSchoolYear: { clazz: Class; schoolYear?: SchoolYearEntity }[] = await Promise.all( + classes.map(async (clazz: Class) => { + let schoolYear: SchoolYearEntity | undefined; + if (clazz.year) { + schoolYear = await this.schoolYearService.findById(clazz.year); + } + + return { + clazz, + schoolYear, + }; + }) + ); + + return classesWithSchoolYear; + } + + private isClassOfQueryType( + currentYear: SchoolYearEntity, + schoolYear?: SchoolYearEntity, + schoolYearQueryType?: SchoolYearQueryType + ): boolean { + if (schoolYearQueryType === undefined) { + return true; + } + + if (schoolYear === undefined) { + return schoolYearQueryType === SchoolYearQueryType.CURRENT_YEAR; + } + + switch (schoolYearQueryType) { + case SchoolYearQueryType.CURRENT_YEAR: + return schoolYear.startDate === currentYear.startDate; + case SchoolYearQueryType.NEXT_YEAR: + return schoolYear.startDate > currentYear.startDate; + case SchoolYearQueryType.PREVIOUS_YEARS: + return schoolYear.startDate < currentYear.startDate; + default: + throw new UnknownQueryTypeLoggableException(schoolYearQueryType); + } + } + + private mapClassInfosFromClasses( + filteredClassesForSchoolYear: { clazz: Class; schoolYear?: SchoolYearEntity }[] + ): ClassInfoDto[] { + const classInfosFromClasses: ClassInfoDto[] = filteredClassesForSchoolYear.map( + (classWithSchoolYear): ClassInfoDto => { + const teachers: UserDO[] = []; + + const mapped: ClassInfoDto = GroupUcMapper.mapClassToClassInfoDto( + classWithSchoolYear.clazz, + teachers, + classWithSchoolYear.schoolYear + ); + + return mapped; + } + ); + return classInfosFromClasses; + } + + private async findGroupsForSchool(schoolId: EntityId): Promise { + const filter: IGroupFilter = { schoolId }; + + const groups: Page = await this.groupService.findGroups(filter); + + const classInfosFromGroups: ClassInfoDto[] = await this.getClassInfosFromGroups(groups.data); + + return classInfosFromGroups; + } + + private async findGroupsForUser(userId: EntityId): Promise { + const filter: IGroupFilter = { userId }; + + const groups: Page = await this.groupService.findGroups(filter); + + const classInfosFromGroups: ClassInfoDto[] = await this.getClassInfosFromGroups(groups.data); + + return classInfosFromGroups; + } + + private async getClassInfosFromGroups(groups: Group[]): Promise { + const systemMap: Map = await this.findSystemNamesForGroups(groups); + + const classInfosFromGroups: ClassInfoDto[] = await Promise.all( + groups.map(async (group: Group): Promise => this.getClassInfoFromGroup(group, systemMap)) + ); + + return classInfosFromGroups; + } + + private async getClassInfoFromGroup(group: Group, systemMap: Map): Promise { + let system: SystemDto | undefined; + if (group.externalSource) { + system = systemMap.get(group.externalSource.systemId); + } + + const resolvedUsers: ResolvedGroupUser[] = []; + + let synchronizedCourses: Course[] = []; + if (this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { + synchronizedCourses = await this.courseService.findBySyncedGroup(group); + } + + const mapped: ClassInfoDto = GroupUcMapper.mapGroupToClassInfoDto( + group, + resolvedUsers, + synchronizedCourses, + system + ); + + return mapped; + } + + private async findSystemNamesForGroups(groups: Group[]): Promise> { + const systemIds: EntityId[] = groups + .map((group: Group): string | undefined => group.externalSource?.systemId) + .filter((systemId: string | undefined): systemId is EntityId => systemId !== undefined); + + const uniqueSystemIds: EntityId[] = Array.from(new Set(systemIds)); + + const systems: Map = new Map(); + + await Promise.all( + uniqueSystemIds.map(async (systemId: string): Promise => { + const system: SystemDto = await this.systemService.findById(systemId); + + systems.set(systemId, system); + }) + ); + + return systems; + } + + private applyPagination(combinedClassInfo: ClassInfoDto[], skip: number, limit: number | undefined): ClassInfoDto[] { + let page: ClassInfoDto[]; + + if (limit === -1) { + page = combinedClassInfo.slice(skip); + } else { + page = combinedClassInfo.slice(skip, limit ? skip + limit : combinedClassInfo.length); + } + + return page; + } +} diff --git a/apps/server/src/modules/group/uc/group.uc.spec.ts b/apps/server/src/modules/group/uc/group.uc.spec.ts index 51da9de9147..e4bb9af1da4 100644 --- a/apps/server/src/modules/group/uc/group.uc.spec.ts +++ b/apps/server/src/modules/group/uc/group.uc.spec.ts @@ -1,44 +1,31 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ObjectId } from '@mikro-orm/mongodb'; -import { Action, AuthorizationContext, AuthorizationService } from '@modules/authorization'; -import { ClassService } from '@modules/class'; -import { Class } from '@modules/class/domain'; -import { classFactory } from '@modules/class/domain/testing/factory/class.factory'; -import { Course } from '@modules/learnroom/domain'; -import { CourseDoService } from '@modules/learnroom/service/course-do.service'; -import { courseFactory } from '@modules/learnroom/testing'; -import { SchoolYearService } from '@modules/legacy-school'; +import { AuthorizationService } from '@modules/authorization'; import { ProvisioningConfig } from '@modules/provisioning'; import { RoleService } from '@modules/role'; import { RoleDto } from '@modules/role/service/dto/role.dto'; import { School, SchoolService } from '@modules/school/domain'; import { schoolFactory } from '@modules/school/testing'; -import { LegacySystemService, SystemDto } from '@modules/system'; import { UserService } from '@modules/user'; import { ForbiddenException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { Page, UserDO } from '@shared/domain/domainobject'; -import { Role, SchoolYearEntity, User } from '@shared/domain/entity'; -import { Permission, SortOrder } from '@shared/domain/interface'; +import { Role, User } from '@shared/domain/entity'; +import { Permission } from '@shared/domain/interface'; import { groupFactory, roleDtoFactory, roleFactory, - schoolYearFactory, setupEntities, UserAndAccountTestFactory, userDoFactory, userFactory, } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { ClassRequestContext, SchoolYearQueryType } from '../controller/dto/interface'; -import { Group, GroupTypes, IGroupFilter } from '../domain'; -import { UnknownQueryTypeLoggableException } from '../loggable'; +import { Group, GroupTypes } from '../domain'; import { GroupService } from '../service'; -import { ClassInfoDto, ResolvedGroupDto } from './dto'; -import { ClassRootType } from './dto/class-root-type'; +import { ResolvedGroupDto } from './dto'; import { GroupUc } from './group.uc'; describe('GroupUc', () => { @@ -46,14 +33,10 @@ describe('GroupUc', () => { let uc: GroupUc; let groupService: DeepMocked; - let classService: DeepMocked; - let systemService: DeepMocked; let userService: DeepMocked; let roleService: DeepMocked; let schoolService: DeepMocked; let authorizationService: DeepMocked; - let schoolYearService: DeepMocked; - let courseService: DeepMocked; let configService: DeepMocked>; // eslint-disable-next-line @typescript-eslint/no-unused-vars let logger: DeepMocked; @@ -66,14 +49,6 @@ describe('GroupUc', () => { provide: GroupService, useValue: createMock(), }, - { - provide: ClassService, - useValue: createMock(), - }, - { - provide: LegacySystemService, - useValue: createMock(), - }, { provide: UserService, useValue: createMock(), @@ -90,14 +65,6 @@ describe('GroupUc', () => { provide: AuthorizationService, useValue: createMock(), }, - { - provide: SchoolYearService, - useValue: createMock(), - }, - { - provide: CourseDoService, - useValue: createMock(), - }, { provide: ConfigService, useValue: createMock(), @@ -111,14 +78,10 @@ describe('GroupUc', () => { uc = module.get(GroupUc); groupService = module.get(GroupService); - classService = module.get(ClassService); - systemService = module.get(LegacySystemService); userService = module.get(UserService); roleService = module.get(RoleService); schoolService = module.get(SchoolService); authorizationService = module.get(AuthorizationService); - schoolYearService = module.get(SchoolYearService); - courseService = module.get(CourseDoService); configService = module.get(ConfigService); logger = module.get(Logger); @@ -133,882 +96,6 @@ describe('GroupUc', () => { jest.resetAllMocks(); }); - describe('findAllClasses', () => { - describe('when the user has no permission', () => { - const setup = () => { - const school: School = schoolFactory.build(); - const user: User = userFactory.buildWithId(); - const error = new ForbiddenException(); - - schoolService.getSchoolById.mockResolvedValue(school); - authorizationService.getUserWithPermissions.mockResolvedValue(user); - authorizationService.checkPermission.mockImplementation(() => { - throw error; - }); - authorizationService.hasAllPermissions.mockReturnValueOnce(false); - - return { - user, - error, - }; - }; - - it('should throw forbidden', async () => { - const { user, error } = setup(); - - const func = () => uc.findAllClasses(user.id, user.school.id); - - await expect(func).rejects.toThrow(error); - }); - }); - - describe('when accessing as a normal user', () => { - const setup = () => { - const school: School = schoolFactory.build({ permissions: { teacher: { STUDENT_LIST: true } } }); - const { studentUser } = UserAndAccountTestFactory.buildStudent(); - const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); - const teacherRole: RoleDto = roleDtoFactory.buildWithId({ - id: teacherUser.roles[0].id, - name: teacherUser.roles[0].name, - }); - const studentRole: RoleDto = roleDtoFactory.buildWithId({ - id: studentUser.roles[0].id, - name: studentUser.roles[0].name, - }); - const teacherUserDo: UserDO = userDoFactory.buildWithId({ - id: teacherUser.id, - lastName: teacherUser.lastName, - roles: [{ id: teacherUser.roles[0].id, name: teacherUser.roles[0].name }], - }); - const studentUserDo: UserDO = userDoFactory.buildWithId({ - id: studentUser.id, - lastName: studentUser.lastName, - roles: [{ id: studentUser.roles[0].id, name: studentUser.roles[0].name }], - }); - const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); - const nextSchoolYear: SchoolYearEntity = schoolYearFactory.buildWithId({ - startDate: schoolYear.endDate, - }); - const clazz: Class = classFactory.build({ - name: 'A', - teacherIds: [teacherUser.id], - source: 'LDAP', - year: schoolYear.id, - }); - const successorClass: Class = classFactory.build({ - name: 'NEW', - teacherIds: [teacherUser.id], - year: nextSchoolYear.id, - }); - const classWithoutSchoolYear = classFactory.build({ - name: 'NoYear', - teacherIds: [teacherUser.id], - year: undefined, - }); - - const system: SystemDto = new SystemDto({ - id: new ObjectId().toHexString(), - displayName: 'External System', - type: 'oauth2', - }); - const group: Group = groupFactory.build({ - name: 'B', - users: [{ userId: teacherUser.id, roleId: teacherUser.roles[0].id }], - externalSource: undefined, - }); - const groupWithSystem: Group = groupFactory.build({ - name: 'C', - externalSource: { externalId: 'externalId', systemId: system.id }, - users: [ - { userId: teacherUser.id, roleId: teacherUser.roles[0].id }, - { userId: studentUser.id, roleId: studentUser.roles[0].id }, - ], - }); - const synchronizedCourse: Course = courseFactory.build({ syncedWithGroup: group.id }); - - schoolService.getSchoolById.mockResolvedValueOnce(school); - authorizationService.getUserWithPermissions.mockResolvedValueOnce(teacherUser); - authorizationService.hasAllPermissions.mockReturnValueOnce(false); - classService.findAllByUserId.mockResolvedValueOnce([clazz, successorClass, classWithoutSchoolYear]); - groupService.findGroups.mockResolvedValueOnce(new Page([group, groupWithSystem], 2)); - classService.findClassesForSchool.mockResolvedValueOnce([clazz, successorClass, classWithoutSchoolYear]); - groupService.findGroups.mockResolvedValueOnce(new Page([group, groupWithSystem], 2)); - systemService.findById.mockResolvedValue(system); - userService.findById.mockImplementation((userId: string): Promise => { - if (userId === teacherUser.id) { - return Promise.resolve(teacherUserDo); - } - - if (userId === studentUser.id) { - return Promise.resolve(studentUserDo); - } - - throw new Error(); - }); - userService.findByIdOrNull.mockImplementation((userId: string): Promise => { - if (userId === teacherUser.id) { - return Promise.resolve(teacherUserDo); - } - - if (userId === studentUser.id) { - return Promise.resolve(studentUserDo); - } - - throw new Error(); - }); - roleService.findById.mockImplementation((roleId: string): Promise => { - if (roleId === teacherUser.roles[0].id) { - return Promise.resolve(teacherRole); - } - - if (roleId === studentUser.roles[0].id) { - return Promise.resolve(studentRole); - } - - throw new Error(); - }); - schoolYearService.findById.mockResolvedValueOnce(schoolYear); - schoolYearService.findById.mockResolvedValueOnce(nextSchoolYear); - schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); - configService.get.mockReturnValueOnce(true); - courseService.findBySyncedGroup.mockResolvedValueOnce([synchronizedCourse]); - courseService.findBySyncedGroup.mockResolvedValueOnce([]); - - return { - teacherUser, - school, - clazz, - successorClass, - classWithoutSchoolYear, - group, - groupWithSystem, - system, - schoolYear, - nextSchoolYear, - synchronizedCourse, - }; - }; - - it('should check the required permissions', async () => { - const { teacherUser, school } = setup(); - - await uc.findAllClasses(teacherUser.id, teacherUser.school.id, SchoolYearQueryType.CURRENT_YEAR); - - expect(authorizationService.checkPermission).toHaveBeenCalledWith<[User, School, AuthorizationContext]>( - teacherUser, - school, - { - action: Action.read, - requiredPermissions: [Permission.CLASS_VIEW, Permission.GROUP_VIEW], - } - ); - }); - - it('should check the access to the full list', async () => { - const { teacherUser } = setup(); - - await uc.findAllClasses(teacherUser.id, teacherUser.school.id); - - expect(authorizationService.hasAllPermissions).toHaveBeenCalledWith<[User, string[]]>(teacherUser, [ - Permission.CLASS_FULL_ADMIN, - Permission.GROUP_FULL_ADMIN, - ]); - }); - - describe('when accessing form course as a teacher', () => { - it('should call findClassesForSchool method from classService', async () => { - const { teacherUser } = setup(); - - await uc.findAllClasses(teacherUser.id, teacherUser.school.id, undefined, ClassRequestContext.COURSE); - - expect(classService.findClassesForSchool).toHaveBeenCalled(); - }); - }); - - describe('when accessing form class overview as a teacher', () => { - it('should call findAllByUserId method from classService', async () => { - const { teacherUser } = setup(); - - await uc.findAllClasses(teacherUser.id, teacherUser.school.id, undefined, ClassRequestContext.CLASS_OVERVIEW); - - expect(classService.findAllByUserId).toHaveBeenCalled(); - }); - }); - - describe('when no pagination is given', () => { - it('should return all classes sorted by name', async () => { - const { - teacherUser, - clazz, - successorClass, - classWithoutSchoolYear, - group, - groupWithSystem, - system, - schoolYear, - nextSchoolYear, - synchronizedCourse, - } = setup(); - - const result: Page = await uc.findAllClasses(teacherUser.id, teacherUser.school.id, undefined); - - expect(result).toEqual>({ - data: [ - { - id: clazz.id, - name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, - type: ClassRootType.CLASS, - externalSourceName: clazz.source, - teacherNames: [], - schoolYear: schoolYear.name, - isUpgradable: false, - studentCount: 2, - }, - { - id: successorClass.id, - name: successorClass.gradeLevel - ? `${successorClass.gradeLevel}${successorClass.name}` - : successorClass.name, - type: ClassRootType.CLASS, - externalSourceName: successorClass.source, - teacherNames: [], - schoolYear: nextSchoolYear.name, - isUpgradable: false, - studentCount: 2, - }, - { - id: classWithoutSchoolYear.id, - name: classWithoutSchoolYear.gradeLevel - ? `${classWithoutSchoolYear.gradeLevel}${classWithoutSchoolYear.name}` - : classWithoutSchoolYear.name, - type: ClassRootType.CLASS, - externalSourceName: classWithoutSchoolYear.source, - teacherNames: [], - isUpgradable: false, - studentCount: 2, - }, - { - id: group.id, - name: group.name, - type: ClassRootType.GROUP, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [{ id: synchronizedCourse.id, name: synchronizedCourse.name }], - }, - { - id: groupWithSystem.id, - name: groupWithSystem.name, - type: ClassRootType.GROUP, - externalSourceName: system.displayName, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [], - }, - ], - total: 5, - }); - }); - - it('should call group service with userId and no pagination', async () => { - const { teacherUser } = setup(); - - await uc.findAllClasses(teacherUser.id, teacherUser.school.id); - - expect(groupService.findGroups).toHaveBeenCalledWith<[IGroupFilter]>({ userId: teacherUser.id }); - }); - }); - - describe('when sorting by external source name in descending order', () => { - it('should return all classes sorted by external source name in descending order', async () => { - const { - teacherUser, - clazz, - classWithoutSchoolYear, - group, - groupWithSystem, - system, - schoolYear, - synchronizedCourse, - } = setup(); - - const result: Page = await uc.findAllClasses( - teacherUser.id, - teacherUser.school.id, - SchoolYearQueryType.CURRENT_YEAR, - undefined, - undefined, - undefined, - 'externalSourceName', - SortOrder.desc - ); - - expect(result).toEqual>({ - data: [ - { - id: classWithoutSchoolYear.id, - name: classWithoutSchoolYear.gradeLevel - ? `${classWithoutSchoolYear.gradeLevel}${classWithoutSchoolYear.name}` - : classWithoutSchoolYear.name, - type: ClassRootType.CLASS, - externalSourceName: classWithoutSchoolYear.source, - teacherNames: [], - isUpgradable: false, - studentCount: 2, - }, - { - id: clazz.id, - name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, - type: ClassRootType.CLASS, - externalSourceName: clazz.source, - teacherNames: [], - schoolYear: schoolYear.name, - isUpgradable: false, - studentCount: 2, - }, - { - id: groupWithSystem.id, - name: groupWithSystem.name, - type: ClassRootType.GROUP, - externalSourceName: system.displayName, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [], - }, - { - id: group.id, - name: group.name, - type: ClassRootType.GROUP, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [{ id: synchronizedCourse.id, name: synchronizedCourse.name }], - }, - ], - total: 4, - }); - }); - }); - - describe('when using pagination', () => { - it('should return the selected page', async () => { - const { teacherUser, group, synchronizedCourse } = setup(); - - const result: Page = await uc.findAllClasses( - teacherUser.id, - teacherUser.school.id, - SchoolYearQueryType.CURRENT_YEAR, - undefined, - 2, - 1, - 'name', - SortOrder.asc - ); - - expect(result).toEqual>({ - data: [ - { - id: group.id, - name: group.name, - type: ClassRootType.GROUP, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [{ id: synchronizedCourse.id, name: synchronizedCourse.name }], - }, - ], - total: 4, - }); - }); - }); - - describe('when querying for classes from next school year', () => { - it('should only return classes from next school year', async () => { - const { teacherUser, successorClass, nextSchoolYear } = setup(); - - const result: Page = await uc.findAllClasses( - teacherUser.id, - teacherUser.school.id, - SchoolYearQueryType.NEXT_YEAR - ); - - expect(result).toEqual>({ - data: [ - { - id: successorClass.id, - name: successorClass.gradeLevel - ? `${successorClass.gradeLevel}${successorClass.name}` - : successorClass.name, - externalSourceName: successorClass.source, - type: ClassRootType.CLASS, - teacherNames: [], - schoolYear: nextSchoolYear.name, - isUpgradable: false, - studentCount: 2, - }, - ], - total: 1, - }); - }); - }); - - describe('when querying for archived classes', () => { - it('should only return classes from previous school years', async () => { - const { teacherUser } = setup(); - - const result: Page = await uc.findAllClasses( - teacherUser.id, - teacherUser.school.id, - SchoolYearQueryType.PREVIOUS_YEARS - ); - - expect(result).toEqual>({ - data: [], - total: 0, - }); - }); - }); - - describe('when querying for not existing type', () => { - it('should throw', async () => { - const { teacherUser } = setup(); - - const func = async () => - uc.findAllClasses(teacherUser.id, teacherUser.school.id, 'notAType' as SchoolYearQueryType); - - await expect(func).rejects.toThrow(UnknownQueryTypeLoggableException); - }); - }); - }); - - describe('when accessing as a user with elevated permission', () => { - const setup = (generateClasses = false) => { - const school: School = schoolFactory.build(); - const { studentUser } = UserAndAccountTestFactory.buildStudent(); - const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); - const { adminUser } = UserAndAccountTestFactory.buildAdmin(); - const teacherRole: RoleDto = roleDtoFactory.buildWithId({ - id: teacherUser.roles[0].id, - name: teacherUser.roles[0].name, - }); - const studentRole: RoleDto = roleDtoFactory.buildWithId({ - id: studentUser.roles[0].id, - name: studentUser.roles[0].name, - }); - const adminUserDo: UserDO = userDoFactory.buildWithId({ - id: adminUser.id, - lastName: adminUser.lastName, - roles: [{ id: adminUser.roles[0].id, name: adminUser.roles[0].name }], - }); - const teacherUserDo: UserDO = userDoFactory.buildWithId({ - id: teacherUser.id, - lastName: teacherUser.lastName, - roles: [{ id: teacherUser.roles[0].id, name: teacherUser.roles[0].name }], - }); - const studentUserDo: UserDO = userDoFactory.buildWithId({ - id: studentUser.id, - lastName: studentUser.lastName, - roles: [{ id: studentUser.roles[0].id, name: studentUser.roles[0].name }], - }); - const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); - let clazzes: Class[] = []; - if (generateClasses) { - clazzes = classFactory.buildList(11, { - name: 'A', - teacherIds: [teacherUser.id], - source: 'LDAP', - year: schoolYear.id, - }); - } - const clazz: Class = classFactory.build({ - name: 'A', - teacherIds: [teacherUser.id], - source: 'LDAP', - year: schoolYear.id, - }); - const system: SystemDto = new SystemDto({ - id: new ObjectId().toHexString(), - displayName: 'External System', - type: 'oauth2', - }); - const group: Group = groupFactory.build({ - name: 'B', - users: [{ userId: teacherUser.id, roleId: teacherUser.roles[0].id }], - externalSource: undefined, - }); - const groupWithSystem: Group = groupFactory.build({ - name: 'C', - externalSource: { externalId: 'externalId', systemId: system.id }, - users: [ - { userId: teacherUser.id, roleId: teacherUser.roles[0].id }, - { userId: studentUser.id, roleId: studentUser.roles[0].id }, - ], - }); - - schoolService.getSchoolById.mockResolvedValueOnce(school); - authorizationService.getUserWithPermissions.mockResolvedValueOnce(adminUser); - authorizationService.hasAllPermissions.mockReturnValueOnce(true); - classService.findClassesForSchool.mockResolvedValueOnce([...clazzes, clazz]); - groupService.findGroups.mockResolvedValueOnce(new Page([group, groupWithSystem], 2)); - systemService.findById.mockResolvedValue(system); - - userService.findById.mockImplementation((userId: string): Promise => { - if (userId === teacherUser.id) { - return Promise.resolve(teacherUserDo); - } - - if (userId === studentUser.id) { - return Promise.resolve(studentUserDo); - } - - if (userId === adminUser.id) { - return Promise.resolve(adminUserDo); - } - - throw new Error(); - }); - userService.findByIdOrNull.mockImplementation((userId: string): Promise => { - if (userId === teacherUser.id) { - return Promise.resolve(teacherUserDo); - } - - if (userId === studentUser.id) { - return Promise.resolve(studentUserDo); - } - - if (userId === adminUser.id) { - return Promise.resolve(adminUserDo); - } - - throw new Error(); - }); - roleService.findById.mockImplementation((roleId: string): Promise => { - if (roleId === teacherUser.roles[0].id) { - return Promise.resolve(teacherRole); - } - - if (roleId === studentUser.roles[0].id) { - return Promise.resolve(studentRole); - } - - throw new Error(); - }); - schoolYearService.findById.mockResolvedValue(schoolYear); - configService.get.mockReturnValueOnce(true); - courseService.findBySyncedGroup.mockResolvedValueOnce([]); - courseService.findBySyncedGroup.mockResolvedValueOnce([]); - - return { - adminUser, - teacherUser, - school, - clazz, - group, - groupWithSystem, - system, - schoolYear, - }; - }; - - it('should check the required permissions', async () => { - const { adminUser, school } = setup(); - - await uc.findAllClasses(adminUser.id, adminUser.school.id); - - expect(authorizationService.checkPermission).toHaveBeenCalledWith<[User, School, AuthorizationContext]>( - adminUser, - school, - { - action: Action.read, - requiredPermissions: [Permission.CLASS_VIEW, Permission.GROUP_VIEW], - } - ); - }); - - it('should check the access to the full list', async () => { - const { adminUser } = setup(); - - await uc.findAllClasses(adminUser.id, adminUser.school.id); - - expect(authorizationService.hasAllPermissions).toHaveBeenCalledWith<[User, string[]]>(adminUser, [ - Permission.CLASS_FULL_ADMIN, - Permission.GROUP_FULL_ADMIN, - ]); - }); - - describe('when no pagination is given', () => { - it('should return all classes sorted by name', async () => { - const { adminUser, clazz, group, groupWithSystem, system, schoolYear } = setup(); - - const result: Page = await uc.findAllClasses(adminUser.id, adminUser.school.id); - - expect(result).toEqual>({ - data: [ - { - id: clazz.id, - name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, - type: ClassRootType.CLASS, - externalSourceName: clazz.source, - teacherNames: [], - schoolYear: schoolYear.name, - isUpgradable: false, - studentCount: 2, - }, - { - id: group.id, - name: group.name, - type: ClassRootType.GROUP, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [], - }, - { - id: groupWithSystem.id, - name: groupWithSystem.name, - type: ClassRootType.GROUP, - externalSourceName: system.displayName, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [], - }, - ], - total: 3, - }); - }); - - it('should call group service with schoolId and no pagination', async () => { - const { teacherUser } = setup(); - - await uc.findAllClasses(teacherUser.id, teacherUser.school.id); - - expect(groupService.findGroups).toHaveBeenCalledWith<[IGroupFilter]>({ schoolId: teacherUser.school.id }); - }); - }); - - describe('when sorting by external source name in descending order', () => { - it('should return all classes sorted by external source name in descending order', async () => { - const { adminUser, clazz, group, groupWithSystem, system, schoolYear } = setup(); - - const result: Page = await uc.findAllClasses( - adminUser.id, - adminUser.school.id, - undefined, - undefined, - undefined, - undefined, - 'externalSourceName', - SortOrder.desc - ); - - expect(result).toEqual>({ - data: [ - { - id: clazz.id, - name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, - type: ClassRootType.CLASS, - externalSourceName: clazz.source, - teacherNames: [], - schoolYear: schoolYear.name, - isUpgradable: false, - studentCount: 2, - }, - { - id: groupWithSystem.id, - name: groupWithSystem.name, - type: ClassRootType.GROUP, - externalSourceName: system.displayName, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [], - }, - { - id: group.id, - name: group.name, - type: ClassRootType.GROUP, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [], - }, - ], - total: 3, - }); - }); - }); - - describe('when using pagination', () => { - it('should return the selected page', async () => { - const { adminUser, group } = setup(); - - const result: Page = await uc.findAllClasses( - adminUser.id, - adminUser.school.id, - undefined, - undefined, - 1, - 1, - 'name', - SortOrder.asc - ); - - expect(result).toEqual>({ - data: [ - { - id: group.id, - name: group.name, - type: ClassRootType.GROUP, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [], - }, - ], - total: 3, - }); - }); - - it('should return classes with expected limit', async () => { - const { adminUser } = setup(true); - - const result: Page = await uc.findAllClasses( - adminUser.id, - adminUser.school.id, - undefined, - undefined, - 0, - 5 - ); - - expect(result.data.length).toEqual(5); - }); - - it('should return all classes without limit', async () => { - const { adminUser } = setup(true); - - const result: Page = await uc.findAllClasses( - adminUser.id, - adminUser.school.id, - undefined, - undefined, - 0, - -1 - ); - - expect(result.data.length).toEqual(14); - }); - }); - }); - - describe('when class has a user referenced which is not existing', () => { - const setup = () => { - const school: School = schoolFactory.build(); - const notFoundReferenceId = new ObjectId().toHexString(); - const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); - - const teacherRole: RoleDto = roleDtoFactory.buildWithId({ - id: teacherUser.roles[0].id, - name: teacherUser.roles[0].name, - }); - - const teacherUserDo: UserDO = userDoFactory.buildWithId({ - id: teacherUser.id, - lastName: teacherUser.lastName, - roles: [{ id: teacherUser.roles[0].id, name: teacherUser.roles[0].name }], - }); - - const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); - const clazz: Class = classFactory.build({ - name: 'A', - teacherIds: [teacherUser.id, notFoundReferenceId], - source: 'LDAP', - year: schoolYear.id, - }); - const system: SystemDto = new SystemDto({ - id: new ObjectId().toHexString(), - displayName: 'External System', - type: 'oauth2', - }); - const group: Group = groupFactory.build({ - name: 'B', - users: [ - { userId: teacherUser.id, roleId: teacherUser.roles[0].id }, - { userId: notFoundReferenceId, roleId: teacherUser.roles[0].id }, - ], - externalSource: undefined, - }); - - schoolService.getSchoolById.mockResolvedValueOnce(school); - authorizationService.getUserWithPermissions.mockResolvedValueOnce(teacherUser); - authorizationService.hasAllPermissions.mockReturnValueOnce(false); - classService.findAllByUserId.mockResolvedValueOnce([clazz]); - groupService.findGroups.mockResolvedValueOnce(new Page([group], 1)); - systemService.findById.mockResolvedValue(system); - - userService.findById.mockImplementation((userId: string): Promise => { - if (userId === teacherUser.id) { - return Promise.resolve(teacherUserDo); - } - - throw new Error(); - }); - userService.findByIdOrNull.mockImplementation((userId: string): Promise => { - if (userId === teacherUser.id) { - return Promise.resolve(teacherUserDo); - } - - if (userId === notFoundReferenceId) { - return Promise.resolve(null); - } - - throw new Error(); - }); - roleService.findById.mockImplementation((roleId: string): Promise => { - if (roleId === teacherUser.roles[0].id) { - return Promise.resolve(teacherRole); - } - - throw new Error(); - }); - schoolYearService.findById.mockResolvedValue(schoolYear); - configService.get.mockReturnValueOnce(true); - courseService.findBySyncedGroup.mockResolvedValueOnce([]); - - return { - teacherUser, - clazz, - group, - notFoundReferenceId, - schoolYear, - }; - }; - - it('should return class without missing user', async () => { - const { teacherUser, clazz, group, schoolYear } = setup(); - - const result = await uc.findAllClasses(teacherUser.id, teacherUser.school.id); - - expect(result).toEqual>({ - data: [ - { - id: clazz.id, - name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, - type: ClassRootType.CLASS, - externalSourceName: clazz.source, - teacherNames: [], - schoolYear: schoolYear.name, - isUpgradable: false, - studentCount: 2, - }, - { - id: group.id, - name: group.name, - type: ClassRootType.GROUP, - teacherNames: [], - studentCount: 0, - synchronizedCourses: [], - }, - ], - total: 2, - }); - }); - }); - }); - describe('getGroup', () => { describe('when the user has no permission', () => { const setup = () => { diff --git a/apps/server/src/modules/group/uc/group.uc.ts b/apps/server/src/modules/group/uc/group.uc.ts index 6f462c48283..74b9de16ca1 100644 --- a/apps/server/src/modules/group/uc/group.uc.ts +++ b/apps/server/src/modules/group/uc/group.uc.ts @@ -1,299 +1,50 @@ import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; -import { ClassService } from '@modules/class'; -import { Class } from '@modules/class/domain'; -import { Course } from '@modules/learnroom/domain'; -import { CourseDoService } from '@modules/learnroom/service/course-do.service'; -import { SchoolYearService } from '@modules/legacy-school'; import { ProvisioningConfig } from '@modules/provisioning'; -import { RoleService } from '@modules/role'; -import { RoleDto } from '@modules/role/service/dto/role.dto'; +import { RoleDto, RoleService } from '@modules/role'; import { School, SchoolService } from '@modules/school/domain'; import { UserService } from '@modules/user'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { SortHelper } from '@shared/common'; import { Page, UserDO } from '@shared/domain/domainobject'; -import { SchoolYearEntity, User } from '@shared/domain/entity'; +import { User } from '@shared/domain/entity'; import { IFindOptions, Permission, SortOrder } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { Logger } from '@src/core/logger'; -import { LegacySystemService, SystemDto } from '@src/modules/system'; -import { ClassRequestContext, SchoolYearQueryType } from '../controller/dto/interface'; import { Group, GroupUser, IGroupFilter } from '../domain'; -import { UnknownQueryTypeLoggableException } from '../loggable'; import { GroupService } from '../service'; -import { ClassInfoDto, ResolvedGroupDto, ResolvedGroupUser } from './dto'; +import { ResolvedGroupDto, ResolvedGroupUser } from './dto'; import { GroupUcMapper } from './mapper/group-uc.mapper'; @Injectable() export class GroupUc { constructor( private readonly groupService: GroupService, - private readonly classService: ClassService, - private readonly systemService: LegacySystemService, private readonly userService: UserService, private readonly roleService: RoleService, private readonly schoolService: SchoolService, private readonly authorizationService: AuthorizationService, - private readonly schoolYearService: SchoolYearService, - private readonly courseService: CourseDoService, private readonly configService: ConfigService, private readonly logger: Logger ) {} - public async findAllClasses( - userId: EntityId, - schoolId: EntityId, - schoolYearQueryType?: SchoolYearQueryType, - calledFrom?: ClassRequestContext, - skip = 0, - limit?: number, - sortBy: keyof ClassInfoDto = 'name', - sortOrder: SortOrder = SortOrder.asc - ): Promise> { - const school: School = await this.schoolService.getSchoolById(schoolId); - - const user: User = await this.authorizationService.getUserWithPermissions(userId); - this.authorizationService.checkPermission( - user, - school, - AuthorizationContextBuilder.read([Permission.CLASS_VIEW, Permission.GROUP_VIEW]) - ); - - const canSeeFullList: boolean = this.authorizationService.hasAllPermissions(user, [ - Permission.CLASS_FULL_ADMIN, - Permission.GROUP_FULL_ADMIN, - ]); - - const calledFromCourse: boolean = - calledFrom === ClassRequestContext.COURSE && school.getPermissions()?.teacher?.STUDENT_LIST === true; - - let combinedClassInfo: ClassInfoDto[]; - if (canSeeFullList || calledFromCourse) { - combinedClassInfo = await this.findCombinedClassListForSchool(schoolId, schoolYearQueryType); - } else { - combinedClassInfo = await this.findCombinedClassListForUser(userId, schoolYearQueryType); - } - - combinedClassInfo.sort((a: ClassInfoDto, b: ClassInfoDto): number => - SortHelper.genericSortFunction(a[sortBy], b[sortBy], sortOrder) - ); - - const pageContent: ClassInfoDto[] = this.applyPagination(combinedClassInfo, skip, limit); - - const page: Page = new Page(pageContent, combinedClassInfo.length); - - return page; - } - - private async findCombinedClassListForSchool( - schoolId: EntityId, - schoolYearQueryType?: SchoolYearQueryType - ): Promise { - let classInfosFromGroups: ClassInfoDto[] = []; - - const classInfosFromClasses = await this.findClassesForSchool(schoolId, schoolYearQueryType); - - if (!schoolYearQueryType || schoolYearQueryType === SchoolYearQueryType.CURRENT_YEAR) { - classInfosFromGroups = await this.findGroupsForSchool(schoolId); - } - - const combinedClassInfo: ClassInfoDto[] = [...classInfosFromClasses, ...classInfosFromGroups]; - - return combinedClassInfo; - } - - private async findCombinedClassListForUser( - userId: EntityId, - schoolYearQueryType?: SchoolYearQueryType - ): Promise { - let classInfosFromGroups: ClassInfoDto[] = []; - - const classInfosFromClasses = await this.findClassesForUser(userId, schoolYearQueryType); - - if (!schoolYearQueryType || schoolYearQueryType === SchoolYearQueryType.CURRENT_YEAR) { - classInfosFromGroups = await this.findGroupsForUser(userId); - } - - const combinedClassInfo: ClassInfoDto[] = [...classInfosFromClasses, ...classInfosFromGroups]; - - return combinedClassInfo; - } - - private async findClassesForSchool( - schoolId: EntityId, - schoolYearQueryType?: SchoolYearQueryType - ): Promise { - const classes: Class[] = await this.classService.findClassesForSchool(schoolId); - - const classInfosFromClasses: ClassInfoDto[] = await this.getClassInfosFromClasses(classes, schoolYearQueryType); - - return classInfosFromClasses; - } - - private async findClassesForUser( - userId: EntityId, - schoolYearQueryType?: SchoolYearQueryType - ): Promise { - const classes: Class[] = await this.classService.findAllByUserId(userId); - - const classInfosFromClasses: ClassInfoDto[] = await this.getClassInfosFromClasses(classes, schoolYearQueryType); - - return classInfosFromClasses; - } - - private async getClassInfosFromClasses( - classes: Class[], - schoolYearQueryType?: SchoolYearQueryType - ): Promise { - const currentYear: SchoolYearEntity = await this.schoolYearService.getCurrentSchoolYear(); - - const classesWithSchoolYear: { clazz: Class; schoolYear?: SchoolYearEntity }[] = await this.addSchoolYearsToClasses( - classes - ); - - const filteredClassesForSchoolYear = classesWithSchoolYear.filter((classWithSchoolYear) => - this.isClassOfQueryType(currentYear, classWithSchoolYear.schoolYear, schoolYearQueryType) - ); - - const classInfosFromClasses: ClassInfoDto[] = this.mapClassInfosFromClasses(filteredClassesForSchoolYear); - - return classInfosFromClasses; - } - - private async addSchoolYearsToClasses(classes: Class[]): Promise<{ clazz: Class; schoolYear?: SchoolYearEntity }[]> { - const classesWithSchoolYear: { clazz: Class; schoolYear?: SchoolYearEntity }[] = await Promise.all( - classes.map(async (clazz: Class) => { - let schoolYear: SchoolYearEntity | undefined; - if (clazz.year) { - schoolYear = await this.schoolYearService.findById(clazz.year); - } - - return { - clazz, - schoolYear, - }; - }) - ); - - return classesWithSchoolYear; - } - - private isClassOfQueryType( - currentYear: SchoolYearEntity, - schoolYear?: SchoolYearEntity, - schoolYearQueryType?: SchoolYearQueryType - ): boolean { - if (schoolYearQueryType === undefined) { - return true; - } - - if (schoolYear === undefined) { - return schoolYearQueryType === SchoolYearQueryType.CURRENT_YEAR; - } - - switch (schoolYearQueryType) { - case SchoolYearQueryType.CURRENT_YEAR: - return schoolYear.startDate === currentYear.startDate; - case SchoolYearQueryType.NEXT_YEAR: - return schoolYear.startDate > currentYear.startDate; - case SchoolYearQueryType.PREVIOUS_YEARS: - return schoolYear.startDate < currentYear.startDate; - default: - throw new UnknownQueryTypeLoggableException(schoolYearQueryType); - } - } - - private mapClassInfosFromClasses( - filteredClassesForSchoolYear: { clazz: Class; schoolYear?: SchoolYearEntity }[] - ): ClassInfoDto[] { - const classInfosFromClasses: ClassInfoDto[] = filteredClassesForSchoolYear.map( - (classWithSchoolYear): ClassInfoDto => { - const teachers: UserDO[] = []; - - const mapped: ClassInfoDto = GroupUcMapper.mapClassToClassInfoDto( - classWithSchoolYear.clazz, - teachers, - classWithSchoolYear.schoolYear - ); - - return mapped; - } - ); - return classInfosFromClasses; - } - - private async findGroupsForSchool(schoolId: EntityId): Promise { - const filter: IGroupFilter = { schoolId }; - - const groups: Page = await this.groupService.findGroups(filter); - - const classInfosFromGroups: ClassInfoDto[] = await this.getClassInfosFromGroups(groups.data); - - return classInfosFromGroups; - } - - private async findGroupsForUser(userId: EntityId): Promise { - const filter: IGroupFilter = { userId }; - - const groups: Page = await this.groupService.findGroups(filter); - - const classInfosFromGroups: ClassInfoDto[] = await this.getClassInfosFromGroups(groups.data); - - return classInfosFromGroups; - } + public async getGroup(userId: EntityId, groupId: EntityId): Promise { + const group: Group = await this.groupService.findById(groupId); - private async getClassInfosFromGroups(groups: Group[]): Promise { - const systemMap: Map = await this.findSystemNamesForGroups(groups); + await this.checkPermission(userId, group); - const classInfosFromGroups: ClassInfoDto[] = await Promise.all( - groups.map(async (group: Group): Promise => this.getClassInfoFromGroup(group, systemMap)) - ); + const resolvedUsers: ResolvedGroupUser[] = await this.findUsersForGroup(group); + const resolvedGroup: ResolvedGroupDto = GroupUcMapper.mapToResolvedGroupDto(group, resolvedUsers); - return classInfosFromGroups; + return resolvedGroup; } - private async getClassInfoFromGroup(group: Group, systemMap: Map): Promise { - let system: SystemDto | undefined; - if (group.externalSource) { - system = systemMap.get(group.externalSource.systemId); - } - - const resolvedUsers: ResolvedGroupUser[] = []; - - let synchronizedCourses: Course[] = []; - if (this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { - synchronizedCourses = await this.courseService.findBySyncedGroup(group); - } - - const mapped: ClassInfoDto = GroupUcMapper.mapGroupToClassInfoDto( + private async checkPermission(userId: EntityId, group: Group): Promise { + const user: User = await this.authorizationService.getUserWithPermissions(userId); + return this.authorizationService.checkPermission( + user, group, - resolvedUsers, - synchronizedCourses, - system - ); - - return mapped; - } - - private async findSystemNamesForGroups(groups: Group[]): Promise> { - const systemIds: EntityId[] = groups - .map((group: Group): string | undefined => group.externalSource?.systemId) - .filter((systemId: string | undefined): systemId is EntityId => systemId !== undefined); - - const uniqueSystemIds: EntityId[] = Array.from(new Set(systemIds)); - - const systems: Map = new Map(); - - await Promise.all( - uniqueSystemIds.map(async (systemId: string): Promise => { - const system: SystemDto = await this.systemService.findById(systemId); - - systems.set(systemId, system); - }) + AuthorizationContextBuilder.read([Permission.GROUP_VIEW]) ); - - return systems; } private async findUsersForGroup(group: Group): Promise { @@ -327,39 +78,6 @@ export class GroupUc { return resolvedGroupUsers; } - private applyPagination(combinedClassInfo: ClassInfoDto[], skip: number, limit: number | undefined): ClassInfoDto[] { - let page: ClassInfoDto[]; - - if (limit === -1) { - page = combinedClassInfo.slice(skip); - } else { - page = combinedClassInfo.slice(skip, limit ? skip + limit : combinedClassInfo.length); - } - - return page; - } - - // ----------------------------------------------------------------------------------------------------------------- - public async getGroup(userId: EntityId, groupId: EntityId): Promise { - const group: Group = await this.groupService.findById(groupId); - - await this.checkPermission(userId, group); - - const resolvedUsers: ResolvedGroupUser[] = await this.findUsersForGroup(group); - const resolvedGroup: ResolvedGroupDto = GroupUcMapper.mapToResolvedGroupDto(group, resolvedUsers); - - return resolvedGroup; - } - - private async checkPermission(userId: EntityId, group: Group): Promise { - const user: User = await this.authorizationService.getUserWithPermissions(userId); - return this.authorizationService.checkPermission( - user, - group, - AuthorizationContextBuilder.read([Permission.GROUP_VIEW]) - ); - } - public async getAllGroups( userId: EntityId, schoolId: EntityId, diff --git a/apps/server/src/modules/group/uc/index.ts b/apps/server/src/modules/group/uc/index.ts index 3f268fdf74c..da20bd0257e 100644 --- a/apps/server/src/modules/group/uc/index.ts +++ b/apps/server/src/modules/group/uc/index.ts @@ -1 +1,2 @@ -export * from './group.uc'; +export { GroupUc } from './group.uc'; +export { ClassGroupUc } from './class-group.uc'; From 87b21342bf8dc4a7912157a42ab587520ea41a0e Mon Sep 17 00:00:00 2001 From: Igor Richter Date: Wed, 8 May 2024 12:24:44 +0200 Subject: [PATCH 08/11] add new uc to module --- apps/server/src/modules/group/group-api.module.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/modules/group/group-api.module.ts b/apps/server/src/modules/group/group-api.module.ts index ab6de7f1bd8..613ae154602 100644 --- a/apps/server/src/modules/group/group-api.module.ts +++ b/apps/server/src/modules/group/group-api.module.ts @@ -10,7 +10,7 @@ import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; import { GroupController } from './controller'; import { GroupModule } from './group.module'; -import { GroupUc } from './uc'; +import { ClassGroupUc, GroupUc } from './uc'; @Module({ imports: [ @@ -26,6 +26,6 @@ import { GroupUc } from './uc'; LearnroomModule, ], controllers: [GroupController], - providers: [GroupUc], + providers: [GroupUc, ClassGroupUc], }) export class GroupApiModule {} From 2455c6ce3b8903e3f017485c91356643408ab646 Mon Sep 17 00:00:00 2001 From: Igor Richter Date: Mon, 13 May 2024 11:54:24 +0200 Subject: [PATCH 09/11] requested changes --- .../group/domain/interface/group-filter.ts | 1 + .../modules/group/uc/class-group.uc.spec.ts | 1 - .../src/modules/group/uc/group.uc.spec.ts | 58 +++++++++++++++++- apps/server/src/modules/group/uc/group.uc.ts | 61 ++++++------------- ...lconnex-group-provisioning.service.spec.ts | 9 +++ 5 files changed, 84 insertions(+), 46 deletions(-) diff --git a/apps/server/src/modules/group/domain/interface/group-filter.ts b/apps/server/src/modules/group/domain/interface/group-filter.ts index 3e65e8edf61..b3e2c5d4beb 100644 --- a/apps/server/src/modules/group/domain/interface/group-filter.ts +++ b/apps/server/src/modules/group/domain/interface/group-filter.ts @@ -7,4 +7,5 @@ export interface IGroupFilter { systemId?: EntityId; groupTypes?: GroupTypes[]; nameQuery?: string; + availableGroupsForCourseSync?: boolean; } diff --git a/apps/server/src/modules/group/uc/class-group.uc.spec.ts b/apps/server/src/modules/group/uc/class-group.uc.spec.ts index 7fc75d46042..a33cb7f263e 100644 --- a/apps/server/src/modules/group/uc/class-group.uc.spec.ts +++ b/apps/server/src/modules/group/uc/class-group.uc.spec.ts @@ -52,7 +52,6 @@ describe('ClassGroupUc', () => { let schoolYearService: DeepMocked; let courseService: DeepMocked; let configService: DeepMocked>; - // eslint-disable-next-line @typescript-eslint/no-unused-vars beforeAll(async () => { module = await Test.createTestingModule({ diff --git a/apps/server/src/modules/group/uc/group.uc.spec.ts b/apps/server/src/modules/group/uc/group.uc.spec.ts index e4bb9af1da4..cded865cb00 100644 --- a/apps/server/src/modules/group/uc/group.uc.spec.ts +++ b/apps/server/src/modules/group/uc/group.uc.spec.ts @@ -38,7 +38,6 @@ describe('GroupUc', () => { let schoolService: DeepMocked; let authorizationService: DeepMocked; let configService: DeepMocked>; - // eslint-disable-next-line @typescript-eslint/no-unused-vars let logger: DeepMocked; beforeAll(async () => { @@ -236,6 +235,63 @@ describe('GroupUc', () => { }); }); }); + + describe('when user in group is not found', () => { + const setup = () => { + const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const { studentUser } = UserAndAccountTestFactory.buildStudent(); + const group: Group = groupFactory.build({ + users: [ + { userId: teacherUser.id, roleId: teacherUser.roles[0].id }, + { userId: studentUser.id, roleId: studentUser.roles[0].id }, + ], + }); + const teacherRole: RoleDto = roleDtoFactory.build({ + id: teacherUser.roles[0].id, + name: teacherUser.roles[0].name, + }); + const studentRole: RoleDto = roleDtoFactory.build({ + id: studentUser.roles[0].id, + name: studentUser.roles[0].name, + }); + const teacherUserDo: UserDO = userDoFactory.build({ + id: teacherUser.id, + firstName: teacherUser.firstName, + lastName: teacherUser.lastName, + email: teacherUser.email, + roles: [{ id: teacherUser.roles[0].id, name: teacherUser.roles[0].name }], + }); + const studentUserDo: UserDO = userDoFactory.build({ + id: studentUser.id, + firstName: teacherUser.firstName, + lastName: studentUser.lastName, + email: studentUser.email, + roles: [{ id: studentUser.roles[0].id, name: studentUser.roles[0].name }], + }); + + groupService.findById.mockResolvedValueOnce(group); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(teacherUser); + userService.findById.mockResolvedValueOnce(teacherUserDo); + userService.findByIdOrNull.mockResolvedValueOnce(teacherUserDo); + roleService.findById.mockResolvedValueOnce(teacherRole); + userService.findById.mockResolvedValueOnce(studentUserDo); + userService.findByIdOrNull.mockResolvedValueOnce(null); + roleService.findById.mockResolvedValueOnce(studentRole); + + return { + teacherId: teacherUser.id, + group, + }; + }; + + it('should log missing user', async () => { + const { teacherId, group } = setup(); + + await uc.getGroup(teacherId, group.id); + + expect(logger.warning).toHaveBeenCalled(); + }); + }); }); describe('getAllGroups', () => { diff --git a/apps/server/src/modules/group/uc/group.uc.ts b/apps/server/src/modules/group/uc/group.uc.ts index 74b9de16ca1..59a3bcad462 100644 --- a/apps/server/src/modules/group/uc/group.uc.ts +++ b/apps/server/src/modules/group/uc/group.uc.ts @@ -5,6 +5,7 @@ import { School, SchoolService } from '@modules/school/domain'; import { UserService } from '@modules/user'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { ReferencedEntityNotFoundLoggable } from '@shared/common/loggable'; import { Page, UserDO } from '@shared/domain/domainobject'; import { User } from '@shared/domain/entity'; import { IFindOptions, Permission, SortOrder } from '@shared/domain/interface'; @@ -29,8 +30,9 @@ export class GroupUc { public async getGroup(userId: EntityId, groupId: EntityId): Promise { const group: Group = await this.groupService.findById(groupId); + const user: User = await this.authorizationService.getUserWithPermissions(userId); - await this.checkPermission(userId, group); + this.authorizationService.checkPermission(user, group, AuthorizationContextBuilder.read([Permission.GROUP_VIEW])); const resolvedUsers: ResolvedGroupUser[] = await this.findUsersForGroup(group); const resolvedGroup: ResolvedGroupDto = GroupUcMapper.mapToResolvedGroupDto(group, resolvedUsers); @@ -38,26 +40,17 @@ export class GroupUc { return resolvedGroup; } - private async checkPermission(userId: EntityId, group: Group): Promise { - const user: User = await this.authorizationService.getUserWithPermissions(userId); - return this.authorizationService.checkPermission( - user, - group, - AuthorizationContextBuilder.read([Permission.GROUP_VIEW]) - ); - } - private async findUsersForGroup(group: Group): Promise { const resolvedGroupUsersOrNull: (ResolvedGroupUser | null)[] = await Promise.all( group.users.map(async (groupUser: GroupUser): Promise => { const user: UserDO | null = await this.userService.findByIdOrNull(groupUser.userId); let resolvedGroup: ResolvedGroupUser | null = null; - /* TODO add this log back later - this.logger.warning( + if (!user) { + this.logger.warning( new ReferencedEntityNotFoundLoggable(Group.name, group.id, UserDO.name, groupUser.userId) - ); */ - if (user) { + ); + } else { const role: RoleDto = await this.roleService.findById(groupUser.roleId); resolvedGroup = new ResolvedGroupUser({ @@ -95,13 +88,18 @@ export class GroupUc { const filter: IGroupFilter = { nameQuery }; options.order = { name: SortOrder.asc }; - let groups: Page; if (canSeeFullList) { - groups = await this.getGroupsForSchool(schoolId, filter, options, availableGroupsForCourseSync); + filter.schoolId = schoolId; } else { - groups = await this.getGroupsForUser(userId, filter, options, availableGroupsForCourseSync); + filter.userId = userId; + } + + if (this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { + filter.availableGroupsForCourseSync = availableGroupsForCourseSync; } + const groups: Page = await this.getGroups(filter, options); + const resolvedGroups: ResolvedGroupDto[] = await Promise.all( groups.data.map(async (group: Group) => { const resolvedUsers: ResolvedGroupUser[] = await this.findUsersForGroup(group); @@ -116,34 +114,9 @@ export class GroupUc { return page; } - private async getGroupsForSchool( - schoolId: EntityId, - filter: IGroupFilter, - options: IFindOptions, - availableGroupsForCourseSync?: boolean - ): Promise> { - filter.schoolId = schoolId; - - let foundGroups: Page; - if (availableGroupsForCourseSync && this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { - foundGroups = await this.groupService.findAvailableGroups(filter, options); - } else { - foundGroups = await this.groupService.findGroups(filter, options); - } - - return foundGroups; - } - - private async getGroupsForUser( - userId: EntityId, - filter: IGroupFilter, - options: IFindOptions, - availableGroupsForCourseSync?: boolean - ): Promise> { - filter.userId = userId; - + private async getGroups(filter: IGroupFilter, options: IFindOptions): Promise> { let foundGroups: Page; - if (availableGroupsForCourseSync && this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { + if (filter.availableGroupsForCourseSync) { foundGroups = await this.groupService.findAvailableGroups(filter, options); } else { foundGroups = await this.groupService.findGroups(filter, options); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.spec.ts index 39deea9992b..e8c1f9f3d37 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.spec.ts @@ -658,9 +658,18 @@ describe(SchulconnexGroupProvisioningService.name, () => { externalGroups, systemId, externalUserId, + user, }; }; + it('should find groups', async () => { + const { externalGroups, systemId, externalUserId, user } = setup(); + + await service.removeExternalGroupsAndAffiliation(externalUserId, externalGroups, systemId); + + expect(groupService.findGroups).toHaveBeenCalledWith({ userId: user.id }); + }); + it('should not save the group', async () => { const { externalGroups, systemId, externalUserId } = setup(); From 9a1ab8c9a474acdef4ef5867124c437d81f924d2 Mon Sep 17 00:00:00 2001 From: Igor Richter Date: Mon, 13 May 2024 12:44:19 +0200 Subject: [PATCH 10/11] requested changes --- .../src/modules/group/controller/group.controller.ts | 3 +-- apps/server/src/modules/group/uc/class-group.uc.ts | 9 ++++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/server/src/modules/group/controller/group.controller.ts b/apps/server/src/modules/group/controller/group.controller.ts index f8bdae2ad28..9bc3a037046 100644 --- a/apps/server/src/modules/group/controller/group.controller.ts +++ b/apps/server/src/modules/group/controller/group.controller.ts @@ -44,8 +44,7 @@ export class GroupController { currentUser.schoolId, filterParams.type, callerParams.calledFrom, - pagination.skip, - pagination.limit, + pagination, sortingQuery.sortBy, sortingQuery.sortOrder ); diff --git a/apps/server/src/modules/group/uc/class-group.uc.ts b/apps/server/src/modules/group/uc/class-group.uc.ts index e81df5df037..d66c8029262 100644 --- a/apps/server/src/modules/group/uc/class-group.uc.ts +++ b/apps/server/src/modules/group/uc/class-group.uc.ts @@ -11,7 +11,7 @@ import { ConfigService } from '@nestjs/config'; import { SortHelper } from '@shared/common'; import { Page, UserDO } from '@shared/domain/domainobject'; import { SchoolYearEntity, User } from '@shared/domain/entity'; -import { Permission, SortOrder } from '@shared/domain/interface'; +import { Pagination, Permission, SortOrder } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { LegacySystemService, SystemDto } from '@src/modules/system'; import { ClassRequestContext, SchoolYearQueryType } from '../controller/dto/interface'; @@ -39,8 +39,7 @@ export class ClassGroupUc { schoolId: EntityId, schoolYearQueryType?: SchoolYearQueryType, calledFrom?: ClassRequestContext, - skip = 0, - limit?: number, + pagination?: Pagination, sortBy: keyof ClassInfoDto = 'name', sortOrder: SortOrder = SortOrder.asc ): Promise> { @@ -72,7 +71,7 @@ export class ClassGroupUc { SortHelper.genericSortFunction(a[sortBy], b[sortBy], sortOrder) ); - const pageContent: ClassInfoDto[] = this.applyPagination(combinedClassInfo, skip, limit); + const pageContent: ClassInfoDto[] = this.applyPagination(combinedClassInfo, pagination?.skip, pagination?.limit); const page: Page = new Page(pageContent, combinedClassInfo.length); @@ -289,7 +288,7 @@ export class ClassGroupUc { return systems; } - private applyPagination(combinedClassInfo: ClassInfoDto[], skip: number, limit: number | undefined): ClassInfoDto[] { + private applyPagination(combinedClassInfo: ClassInfoDto[], skip = 0, limit?: number): ClassInfoDto[] { let page: ClassInfoDto[]; if (limit === -1) { From 4e2f9359f1ef30878d2f906be427c05035b74bc0 Mon Sep 17 00:00:00 2001 From: Igor Richter Date: Mon, 13 May 2024 13:19:37 +0200 Subject: [PATCH 11/11] requested changes --- apps/server/src/modules/group/domain/index.ts | 2 +- .../group/domain/interface/group-filter.ts | 3 +-- .../modules/group/domain/interface/index.ts | 2 +- .../src/modules/group/repo/group.repo.ts | 6 +++--- .../modules/group/service/group.service.ts | 6 +++--- .../modules/group/uc/class-group.uc.spec.ts | 20 +++++++------------ .../src/modules/group/uc/class-group.uc.ts | 6 +++--- apps/server/src/modules/group/uc/group.uc.ts | 18 ++++++++--------- ...nex-provisioning-options-update.service.ts | 4 ++-- .../schulconnex-group-provisioning.service.ts | 4 ++-- 10 files changed, 32 insertions(+), 39 deletions(-) diff --git a/apps/server/src/modules/group/domain/index.ts b/apps/server/src/modules/group/domain/index.ts index 40845634539..713cae7ac17 100644 --- a/apps/server/src/modules/group/domain/index.ts +++ b/apps/server/src/modules/group/domain/index.ts @@ -2,4 +2,4 @@ export * from './group'; export * from './group-user'; export * from './group-types'; export { GroupDeletedEvent } from './event'; -export { IGroupFilter } from './interface'; +export { GroupFilter } from './interface'; diff --git a/apps/server/src/modules/group/domain/interface/group-filter.ts b/apps/server/src/modules/group/domain/interface/group-filter.ts index b3e2c5d4beb..7baaffbf511 100644 --- a/apps/server/src/modules/group/domain/interface/group-filter.ts +++ b/apps/server/src/modules/group/domain/interface/group-filter.ts @@ -1,11 +1,10 @@ import { GroupTypes } from '@modules/group'; import { EntityId } from '@shared/domain/types'; -export interface IGroupFilter { +export interface GroupFilter { userId?: EntityId; schoolId?: EntityId; systemId?: EntityId; groupTypes?: GroupTypes[]; nameQuery?: string; - availableGroupsForCourseSync?: boolean; } diff --git a/apps/server/src/modules/group/domain/interface/index.ts b/apps/server/src/modules/group/domain/interface/index.ts index d94a81d16f2..e9556398024 100644 --- a/apps/server/src/modules/group/domain/interface/index.ts +++ b/apps/server/src/modules/group/domain/interface/index.ts @@ -1 +1 @@ -export { IGroupFilter } from './group-filter'; +export { GroupFilter } from './group-filter'; diff --git a/apps/server/src/modules/group/repo/group.repo.ts b/apps/server/src/modules/group/repo/group.repo.ts index f32405596e3..3c92c427918 100644 --- a/apps/server/src/modules/group/repo/group.repo.ts +++ b/apps/server/src/modules/group/repo/group.repo.ts @@ -7,7 +7,7 @@ import { IFindOptions } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { MongoPatterns } from '@shared/repo'; import { BaseDomainObjectRepo } from '@shared/repo/base-domain-object.repo'; -import { Group, GroupTypes, IGroupFilter } from '../domain'; +import { Group, GroupFilter, GroupTypes } from '../domain'; import { GroupEntity } from '../entity'; import { GroupDomainMapper, GroupTypesToGroupEntityTypesMapping } from './group-domain.mapper'; import { GroupScope } from './group.scope'; @@ -53,7 +53,7 @@ export class GroupRepo extends BaseDomainObjectRepo { return domainObject; } - public async findGroups(filter: IGroupFilter, options?: IFindOptions): Promise> { + public async findGroups(filter: GroupFilter, options?: IFindOptions): Promise> { const scope: GroupScope = new GroupScope(); scope.byUserId(filter.userId); scope.byOrganizationId(filter.schoolId); @@ -82,7 +82,7 @@ export class GroupRepo extends BaseDomainObjectRepo { return page; } - public async findAvailableGroups(filter: IGroupFilter, options?: IFindOptions): Promise> { + public async findAvailableGroups(filter: GroupFilter, options?: IFindOptions): Promise> { const pipeline: unknown[] = []; let nameRegexFilter = {}; diff --git a/apps/server/src/modules/group/service/group.service.ts b/apps/server/src/modules/group/service/group.service.ts index ae6d8711310..bbf41827624 100644 --- a/apps/server/src/modules/group/service/group.service.ts +++ b/apps/server/src/modules/group/service/group.service.ts @@ -5,7 +5,7 @@ import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { Page } from '@shared/domain/domainobject'; import { IFindOptions } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { Group, GroupDeletedEvent, IGroupFilter } from '../domain'; +import { Group, GroupDeletedEvent, GroupFilter } from '../domain'; import { GroupRepo } from '../repo'; @Injectable() @@ -34,13 +34,13 @@ export class GroupService implements AuthorizationLoaderServiceGeneric { return group; } - public async findGroups(filter: IGroupFilter, options?: IFindOptions): Promise> { + public async findGroups(filter: GroupFilter, options?: IFindOptions): Promise> { const groups: Page = await this.groupRepo.findGroups(filter, options); return groups; } - public async findAvailableGroups(filter: IGroupFilter, options?: IFindOptions): Promise> { + public async findAvailableGroups(filter: GroupFilter, options?: IFindOptions): Promise> { const groups: Page = await this.groupRepo.findAvailableGroups(filter, options); return groups; diff --git a/apps/server/src/modules/group/uc/class-group.uc.spec.ts b/apps/server/src/modules/group/uc/class-group.uc.spec.ts index a33cb7f263e..4e3f0d0ceee 100644 --- a/apps/server/src/modules/group/uc/class-group.uc.spec.ts +++ b/apps/server/src/modules/group/uc/class-group.uc.spec.ts @@ -32,7 +32,7 @@ import { userFactory, } from '@shared/testing'; import { ClassRequestContext, SchoolYearQueryType } from '../controller/dto/interface'; -import { Group, IGroupFilter } from '../domain'; +import { Group, GroupFilter } from '../domain'; import { UnknownQueryTypeLoggableException } from '../loggable'; import { GroupService } from '../service'; import { ClassInfoDto } from './dto'; @@ -404,7 +404,7 @@ describe('ClassGroupUc', () => { await uc.findAllClasses(teacherUser.id, teacherUser.school.id); - expect(groupService.findGroups).toHaveBeenCalledWith<[IGroupFilter]>({ userId: teacherUser.id }); + expect(groupService.findGroups).toHaveBeenCalledWith<[GroupFilter]>({ userId: teacherUser.id }); }); }); @@ -427,7 +427,6 @@ describe('ClassGroupUc', () => { SchoolYearQueryType.CURRENT_YEAR, undefined, undefined, - undefined, 'externalSourceName', SortOrder.desc ); @@ -487,8 +486,7 @@ describe('ClassGroupUc', () => { teacherUser.school.id, SchoolYearQueryType.CURRENT_YEAR, undefined, - 2, - 1, + { skip: 2, limit: 1 }, 'name', SortOrder.asc ); @@ -768,7 +766,7 @@ describe('ClassGroupUc', () => { await uc.findAllClasses(teacherUser.id, teacherUser.school.id); - expect(groupService.findGroups).toHaveBeenCalledWith<[IGroupFilter]>({ schoolId: teacherUser.school.id }); + expect(groupService.findGroups).toHaveBeenCalledWith<[GroupFilter]>({ schoolId: teacherUser.school.id }); }); }); @@ -782,7 +780,6 @@ describe('ClassGroupUc', () => { undefined, undefined, undefined, - undefined, 'externalSourceName', SortOrder.desc ); @@ -831,8 +828,7 @@ describe('ClassGroupUc', () => { adminUser.school.id, undefined, undefined, - 1, - 1, + { skip: 1, limit: 1 }, 'name', SortOrder.asc ); @@ -860,8 +856,7 @@ describe('ClassGroupUc', () => { adminUser.school.id, undefined, undefined, - 0, - 5 + { skip: 0, limit: 5 } ); expect(result.data.length).toEqual(5); @@ -875,8 +870,7 @@ describe('ClassGroupUc', () => { adminUser.school.id, undefined, undefined, - 0, - -1 + { skip: 0, limit: -1 } ); expect(result.data.length).toEqual(14); diff --git a/apps/server/src/modules/group/uc/class-group.uc.ts b/apps/server/src/modules/group/uc/class-group.uc.ts index d66c8029262..5f7843c3885 100644 --- a/apps/server/src/modules/group/uc/class-group.uc.ts +++ b/apps/server/src/modules/group/uc/class-group.uc.ts @@ -15,7 +15,7 @@ import { Pagination, Permission, SortOrder } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { LegacySystemService, SystemDto } from '@src/modules/system'; import { ClassRequestContext, SchoolYearQueryType } from '../controller/dto/interface'; -import { Group, IGroupFilter } from '../domain'; +import { Group, GroupFilter } from '../domain'; import { UnknownQueryTypeLoggableException } from '../loggable'; import { GroupService } from '../service'; import { ClassInfoDto, ResolvedGroupUser } from './dto'; @@ -216,7 +216,7 @@ export class ClassGroupUc { } private async findGroupsForSchool(schoolId: EntityId): Promise { - const filter: IGroupFilter = { schoolId }; + const filter: GroupFilter = { schoolId }; const groups: Page = await this.groupService.findGroups(filter); @@ -226,7 +226,7 @@ export class ClassGroupUc { } private async findGroupsForUser(userId: EntityId): Promise { - const filter: IGroupFilter = { userId }; + const filter: GroupFilter = { userId }; const groups: Page = await this.groupService.findGroups(filter); diff --git a/apps/server/src/modules/group/uc/group.uc.ts b/apps/server/src/modules/group/uc/group.uc.ts index 59a3bcad462..714307c6a6d 100644 --- a/apps/server/src/modules/group/uc/group.uc.ts +++ b/apps/server/src/modules/group/uc/group.uc.ts @@ -11,7 +11,7 @@ import { User } from '@shared/domain/entity'; import { IFindOptions, Permission, SortOrder } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { Logger } from '@src/core/logger'; -import { Group, GroupUser, IGroupFilter } from '../domain'; +import { Group, GroupFilter, GroupUser } from '../domain'; import { GroupService } from '../service'; import { ResolvedGroupDto, ResolvedGroupUser } from './dto'; import { GroupUcMapper } from './mapper/group-uc.mapper'; @@ -85,7 +85,7 @@ export class GroupUc { const canSeeFullList: boolean = this.authorizationService.hasAllPermissions(user, [Permission.GROUP_FULL_ADMIN]); - const filter: IGroupFilter = { nameQuery }; + const filter: GroupFilter = { nameQuery }; options.order = { name: SortOrder.asc }; if (canSeeFullList) { @@ -94,11 +94,7 @@ export class GroupUc { filter.userId = userId; } - if (this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { - filter.availableGroupsForCourseSync = availableGroupsForCourseSync; - } - - const groups: Page = await this.getGroups(filter, options); + const groups: Page = await this.getGroups(filter, options, availableGroupsForCourseSync); const resolvedGroups: ResolvedGroupDto[] = await Promise.all( groups.data.map(async (group: Group) => { @@ -114,9 +110,13 @@ export class GroupUc { return page; } - private async getGroups(filter: IGroupFilter, options: IFindOptions): Promise> { + private async getGroups( + filter: GroupFilter, + options: IFindOptions, + availableGroupsForCourseSync?: boolean + ): Promise> { let foundGroups: Page; - if (filter.availableGroupsForCourseSync) { + if (availableGroupsForCourseSync && this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { foundGroups = await this.groupService.findAvailableGroups(filter, options); } else { foundGroups = await this.groupService.findGroups(filter, options); diff --git a/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.ts b/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.ts index bd9bbf0b365..c1015e5d89f 100644 --- a/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.ts +++ b/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.ts @@ -1,4 +1,4 @@ -import { Group, GroupService, GroupTypes, IGroupFilter } from '@modules/group'; +import { Group, GroupFilter, GroupService, GroupTypes } from '@modules/group'; import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { Page } from '@shared/domain/domainobject'; import { EntityId } from '@shared/domain/types'; @@ -31,7 +31,7 @@ export class SchulconnexProvisioningOptionsUpdateService } private async deleteGroups(schoolId: EntityId, systemId: EntityId, groupType: GroupTypes): Promise { - const filter: IGroupFilter = { schoolId, systemId, groupTypes: [groupType] }; + const filter: GroupFilter = { schoolId, systemId, groupTypes: [groupType] }; const page: Page = await this.groupService.findGroups(filter); const groups: Group[] = page.data; diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.ts index af4c0266394..a25c8c4c6c7 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.ts @@ -1,5 +1,5 @@ import { ObjectId } from '@mikro-orm/mongodb'; -import { Group, GroupService, GroupTypes, GroupUser, IGroupFilter } from '@modules/group'; +import { Group, GroupFilter, GroupService, GroupTypes, GroupUser } from '@modules/group'; import { LegacySchoolService, SchoolSystemOptionsService, @@ -175,7 +175,7 @@ export class SchulconnexGroupProvisioningService { throw new NotFoundLoggableException(UserDO.name, { externalId: externalUserId }); } - const filter: IGroupFilter = { userId: user.id }; + const filter: GroupFilter = { userId: user.id }; const existingGroupsOfUser: Page = await this.groupService.findGroups(filter); const groupsFromSystem: Group[] = existingGroupsOfUser.data.filter(