diff --git a/apps/server/src/modules/tool/common/mapper/tool-context.mapper.ts b/apps/server/src/modules/tool/common/mapper/tool-context.mapper.ts new file mode 100644 index 00000000000..183da9cdbbd --- /dev/null +++ b/apps/server/src/modules/tool/common/mapper/tool-context.mapper.ts @@ -0,0 +1,9 @@ +import { ToolContextType } from '../enum'; +import { ContextExternalToolType } from '../../context-external-tool/entity'; + +export class ToolContextMapper { + static contextMapping: Record = { + [ToolContextType.COURSE]: ContextExternalToolType.COURSE, + [ToolContextType.BOARD_ELEMENT]: ContextExternalToolType.BOARD_ELEMENT, + }; +} diff --git a/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts b/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts index ebff659529d..75dc1229231 100644 --- a/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts +++ b/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts @@ -1,16 +1,19 @@ import { Loaded } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Permission } from '@shared/domain'; +import { Permission, SchoolEntity } from '@shared/domain'; import { cleanupCollections, + contextExternalToolEntityFactory, externalToolEntityFactory, externalToolFactory, + schoolExternalToolEntityFactory, + schoolFactory, TestApiClient, UserAndAccountTestFactory, } from '@shared/testing'; -import { ServerTestModule } from '@modules/server'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { Response } from 'supertest'; @@ -20,8 +23,16 @@ import { CustomParameterTypeParams, ToolConfigType, } from '../../../common/enum'; +import { ContextExternalToolEntity, ContextExternalToolType } from '../../../context-external-tool/entity'; +import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; +import { ExternalToolMetadata } from '../../domain'; import { ExternalToolEntity } from '../../entity'; -import { ExternalToolCreateParams, ExternalToolResponse, ExternalToolSearchListResponse } from '../dto'; +import { + ExternalToolCreateParams, + ExternalToolResponse, + ExternalToolSearchListResponse, + ExternalToolMetadataResponse, +} from '../dto'; describe('ToolController (API)', () => { let app: INestApplication; @@ -617,4 +628,82 @@ describe('ToolController (API)', () => { }); }); }); + + describe('[GET] tools/external-tools/:externalToolId/metadata', () => { + describe('when user is not authenticated', () => { + const setup = () => { + const toolId: string = new ObjectId().toHexString(); + + return { toolId }; + }; + + it('should return unauthorized', async () => { + const { toolId } = setup(); + + const response: Response = await testApiClient.get(`${toolId}/metadata`); + + expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when externalToolId is given ', () => { + const setup = async () => { + const toolId: string = new ObjectId().toHexString(); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(undefined, toolId); + + const school: SchoolEntity = schoolFactory.buildWithId(); + const schoolExternalToolEntitys: SchoolExternalToolEntity[] = schoolExternalToolEntityFactory.buildList(2, { + tool: externalToolEntity, + school, + }); + + const courseTools: ContextExternalToolEntity[] = contextExternalToolEntityFactory.buildList(3, { + schoolTool: schoolExternalToolEntitys[0], + contextType: ContextExternalToolType.COURSE, + }); + + const boardTools: ContextExternalToolEntity[] = contextExternalToolEntityFactory.buildList(2, { + schoolTool: schoolExternalToolEntitys[1], + contextType: ContextExternalToolType.BOARD_ELEMENT, + contextId: new ObjectId().toHexString(), + }); + + const externalToolMetadata: ExternalToolMetadata = new ExternalToolMetadata({ + schoolExternalToolCount: 2, + contextExternalToolCountPerContext: { course: 3, boardElement: 2 }, + }); + + const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({}, [Permission.TOOL_ADMIN]); + await em.persistAndFlush([ + adminAccount, + adminUser, + school, + externalToolEntity, + ...schoolExternalToolEntitys, + ...courseTools, + ...boardTools, + ]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); + + return { loggedInClient, toolId, externalToolEntity, externalToolMetadata }; + }; + + it('should return the metadata of externalTool', async () => { + const { loggedInClient, externalToolEntity } = await setup(); + + const response: Response = await loggedInClient.get(`${externalToolEntity.id}/metadata`); + + expect(response.statusCode).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + schoolExternalToolCount: 2, + contextExternalToolCountPerContext: { + course: 3, + boardElement: 2, + }, + }); + }); + }); + }); }); diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool-metadata.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool-metadata.response.ts new file mode 100644 index 00000000000..d38b48cc503 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool-metadata.response.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ContextExternalToolType } from '../../../../context-external-tool/entity'; + +export class ExternalToolMetadataResponse { + @ApiProperty() + schoolExternalToolCount: number; + + @ApiProperty({ + type: 'object', + properties: Object.fromEntries( + Object.values(ContextExternalToolType).map((key: ContextExternalToolType) => [key, { type: 'number' }]) + ), + }) + contextExternalToolCountPerContext: Record; + + constructor(externalToolMetadataResponse: ExternalToolMetadataResponse) { + this.schoolExternalToolCount = externalToolMetadataResponse.schoolExternalToolCount; + this.contextExternalToolCountPerContext = externalToolMetadataResponse.contextExternalToolCountPerContext; + } +} diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/index.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/index.ts index e9e5fafa376..4f532238863 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/index.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/index.ts @@ -6,3 +6,4 @@ export * from './context-external-tool-configuration-template.response'; export * from './context-external-tool-configuration-template-list.response'; export * from './school-external-tool-configuration-template.response'; export * from './school-external-tool-configuration-template-list.response'; +export * from './external-tool-metadata.response'; diff --git a/apps/server/src/modules/tool/external-tool/controller/tool.controller.ts b/apps/server/src/modules/tool/external-tool/controller/tool.controller.ts index 80139a586cb..448be593367 100644 --- a/apps/server/src/modules/tool/external-tool/controller/tool.controller.ts +++ b/apps/server/src/modules/tool/external-tool/controller/tool.controller.ts @@ -17,9 +17,10 @@ import { LegacyLogger } from '@src/core/logger'; import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; import { Response } from 'express'; import { ExternalToolSearchQuery } from '../../common/interface'; -import { ExternalTool } from '../domain'; +import { ExternalTool, ExternalToolMetadata } from '../domain'; import { ExternalToolLogo } from '../domain/external-tool-logo'; -import { ExternalToolRequestMapper, ExternalToolResponseMapper } from '../mapper'; + +import { ExternalToolRequestMapper, ExternalToolResponseMapper, ExternalToolMetadataMapper } from '../mapper'; import { ExternalToolLogoService } from '../service'; import { ExternalToolCreate, ExternalToolUc, ExternalToolUpdate } from '../uc'; import { @@ -30,6 +31,7 @@ import { ExternalToolSearchParams, ExternalToolUpdateParams, SortExternalToolParams, + ExternalToolMetadataResponse, } from './dto'; @ApiTags('Tool') @@ -165,4 +167,26 @@ export class ToolController { res.setHeader('Cache-Control', 'must-revalidate'); res.send(externalToolLogo.logo); } + + @Get('/:externalToolId/metadata') + @ApiOperation({ summary: 'Gets the metadata of an external tool.' }) + @ApiOkResponse({ + description: 'Metadata of external tool fetched successfully.', + type: ExternalToolMetadataResponse, + }) + @ApiUnauthorizedResponse({ description: 'User is not logged in.' }) + async getMetaDataForExternalTool( + @CurrentUser() currentUser: ICurrentUser, + @Param() params: ExternalToolIdParams + ): Promise { + const externalToolMetadata: ExternalToolMetadata = await this.externalToolUc.getMetadataForExternalTool( + currentUser.userId, + params.externalToolId + ); + + const mapped: ExternalToolMetadataResponse = + ExternalToolMetadataMapper.mapToExternalToolMetadataResponse(externalToolMetadata); + + return mapped; + } } diff --git a/apps/server/src/modules/tool/external-tool/domain/external-tool-metadata.ts b/apps/server/src/modules/tool/external-tool/domain/external-tool-metadata.ts new file mode 100644 index 00000000000..04492680ff7 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/domain/external-tool-metadata.ts @@ -0,0 +1,10 @@ +export class ExternalToolMetadata { + schoolExternalToolCount: number; + + contextExternalToolCountPerContext: Record; + + constructor(externalToolMetadata: ExternalToolMetadata) { + this.schoolExternalToolCount = externalToolMetadata.schoolExternalToolCount; + this.contextExternalToolCountPerContext = externalToolMetadata.contextExternalToolCountPerContext; + } +} diff --git a/apps/server/src/modules/tool/external-tool/domain/index.ts b/apps/server/src/modules/tool/external-tool/domain/index.ts index e5a1dab735d..61fca0d2bfe 100644 --- a/apps/server/src/modules/tool/external-tool/domain/index.ts +++ b/apps/server/src/modules/tool/external-tool/domain/index.ts @@ -1,2 +1,3 @@ export * from './external-tool.do'; export * from './config'; +export * from './external-tool-metadata'; diff --git a/apps/server/src/modules/tool/external-tool/external-tool.module.ts b/apps/server/src/modules/tool/external-tool/external-tool.module.ts index 9aa0a11e448..a84734fecb2 100644 --- a/apps/server/src/modules/tool/external-tool/external-tool.module.ts +++ b/apps/server/src/modules/tool/external-tool/external-tool.module.ts @@ -5,6 +5,8 @@ import { OauthProviderServiceModule } from '@infra/oauth-provider'; import { EncryptionModule } from '@infra/encryption'; import { ExternalToolRepo } from '@shared/repo'; import { ToolConfigModule } from '../tool-config.module'; +import { ExternalToolMetadataMapper } from './mapper'; +import { ToolContextMapper } from '../common/mapper/tool-context.mapper'; import { ExternalToolConfigurationService, ExternalToolLogoService, @@ -13,6 +15,7 @@ import { ExternalToolServiceMapper, ExternalToolValidationService, ExternalToolVersionIncrementService, + ExternalToolMetadataService, } from './service'; import { CommonToolModule } from '../common'; @@ -27,6 +30,9 @@ import { CommonToolModule } from '../common'; ExternalToolConfigurationService, ExternalToolLogoService, ExternalToolRepo, + ExternalToolMetadataService, + ExternalToolMetadataMapper, + ToolContextMapper, ], exports: [ ExternalToolService, @@ -34,6 +40,7 @@ import { CommonToolModule } from '../common'; ExternalToolVersionIncrementService, ExternalToolConfigurationService, ExternalToolLogoService, + ExternalToolMetadataService, ], }) export class ExternalToolModule {} diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-metadata.mapper.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-metadata.mapper.ts new file mode 100644 index 00000000000..b3d6555f898 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-metadata.mapper.ts @@ -0,0 +1,13 @@ +import { ExternalToolMetadataResponse } from '../controller/dto'; +import { ExternalToolMetadata } from '../domain'; + +export class ExternalToolMetadataMapper { + static mapToExternalToolMetadataResponse(externalToolMetadata: ExternalToolMetadata): ExternalToolMetadataResponse { + const externalToolMetadataResponse: ExternalToolMetadataResponse = new ExternalToolMetadataResponse({ + schoolExternalToolCount: externalToolMetadata.schoolExternalToolCount, + contextExternalToolCountPerContext: externalToolMetadata.contextExternalToolCountPerContext, + }); + + return externalToolMetadataResponse; + } +} diff --git a/apps/server/src/modules/tool/external-tool/mapper/index.ts b/apps/server/src/modules/tool/external-tool/mapper/index.ts index 4149a17a519..73fdb05cf5a 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/index.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/index.ts @@ -1,2 +1,3 @@ export * from './external-tool-request.mapper'; export * from './external-tool-response.mapper'; +export * from './external-tool-metadata.mapper'; diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-metadata.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-metadata.service.spec.ts new file mode 100644 index 00000000000..4753cc805f4 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-metadata.service.spec.ts @@ -0,0 +1,145 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ContextExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; +import { externalToolFactory, legacySchoolDoFactory, schoolExternalToolFactory } from '@shared/testing'; +import { ContextExternalToolType } from '../../context-external-tool/entity'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { ExternalTool, ExternalToolMetadata } from '../domain'; +import { ExternalToolMetadataService } from './external-tool-metadata.service'; + +describe('ExternalToolMetadataService', () => { + let module: TestingModule; + let service: ExternalToolMetadataService; + + let schoolExternalToolRepo: DeepMocked; + let contextExternalToolRepo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + ExternalToolMetadataService, + { + provide: SchoolExternalToolRepo, + useValue: createMock(), + }, + { + provide: ContextExternalToolRepo, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(ExternalToolMetadataService); + schoolExternalToolRepo = module.get(SchoolExternalToolRepo); + contextExternalToolRepo = module.get(ContextExternalToolRepo); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getMetadata', () => { + describe('when externalToolId is given', () => { + const setup = () => { + const toolId: string = new ObjectId().toHexString(); + + const school = legacySchoolDoFactory.buildWithId(); + const school1 = legacySchoolDoFactory.buildWithId(); + + const schoolToolId: string = new ObjectId().toHexString(); + const schoolToolId1: string = new ObjectId().toHexString(); + + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId, + schoolId: school.id, + id: schoolToolId, + }); + const schoolExternalTool1: SchoolExternalTool = schoolExternalToolFactory.buildWithId( + { toolId, schoolId: school1.id, id: schoolToolId1 }, + schoolToolId1 + ); + + const externalToolMetadata: ExternalToolMetadata = new ExternalToolMetadata({ + schoolExternalToolCount: 2, + contextExternalToolCountPerContext: { course: 3, boardElement: 3 }, + }); + + schoolExternalToolRepo.findByExternalToolId.mockResolvedValue([schoolExternalTool, schoolExternalTool1]); + contextExternalToolRepo.countBySchoolToolIdsAndContextType.mockResolvedValue(3); + + return { + toolId, + externalToolMetadata, + schoolExternalTool, + schoolExternalTool1, + }; + }; + + it('should call the repo to get schoolExternalTools by externalToolId', async () => { + const { toolId } = setup(); + + await service.getMetadata(toolId); + + expect(schoolExternalToolRepo.findByExternalToolId).toHaveBeenCalledWith(toolId); + }); + + it('should call the repo to count contextExternalTools by schoolExternalToolId and context', async () => { + const { toolId, schoolExternalTool, schoolExternalTool1 } = setup(); + + await service.getMetadata(toolId); + + expect(contextExternalToolRepo.countBySchoolToolIdsAndContextType).toHaveBeenCalledWith( + ContextExternalToolType.COURSE, + [schoolExternalTool.id, schoolExternalTool1.id] + ); + expect(contextExternalToolRepo.countBySchoolToolIdsAndContextType).toHaveBeenCalledWith( + ContextExternalToolType.BOARD_ELEMENT, + [schoolExternalTool.id, schoolExternalTool1.id] + ); + expect(contextExternalToolRepo.countBySchoolToolIdsAndContextType).toHaveBeenCalledTimes(2); + }); + + it('should return externalToolMetadata', async () => { + const { toolId, externalToolMetadata } = setup(); + + const result: ExternalToolMetadata = await service.getMetadata(toolId); + + expect(result).toEqual(externalToolMetadata); + }); + }); + + describe('when no related school external tool was found', () => { + const setup = () => { + const toolId: string = new ObjectId().toHexString(); + const externalToolEntity: ExternalTool = externalToolFactory.buildWithId(undefined, toolId); + + const externalToolMetadata: ExternalToolMetadata = new ExternalToolMetadata({ + schoolExternalToolCount: 0, + contextExternalToolCountPerContext: { course: 0, boardElement: 0 }, + }); + + schoolExternalToolRepo.findByExternalToolId.mockResolvedValue([]); + contextExternalToolRepo.countBySchoolToolIdsAndContextType.mockResolvedValue(0); + + return { + toolId, + externalToolEntity, + externalToolMetadata, + }; + }; + + it('should return empty externalToolMetadata', async () => { + const { toolId, externalToolMetadata } = setup(); + + const result: ExternalToolMetadata = await service.getMetadata(toolId); + + expect(result).toEqual(externalToolMetadata); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-metadata.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-metadata.service.ts new file mode 100644 index 00000000000..9476e738a87 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-metadata.service.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { ContextExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; +import { ToolContextType } from '../../common/enum'; +import { ContextExternalToolType } from '../../context-external-tool/entity'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { ExternalToolMetadata } from '../domain'; +import { ToolContextMapper } from '../../common/mapper/tool-context.mapper'; + +@Injectable() +export class ExternalToolMetadataService { + constructor( + private readonly schoolToolRepo: SchoolExternalToolRepo, + private readonly contextToolRepo: ContextExternalToolRepo + ) {} + + async getMetadata(toolId: EntityId): Promise { + const schoolExternalTools: SchoolExternalTool[] = await this.schoolToolRepo.findByExternalToolId(toolId); + + const schoolExternalToolIds: string[] = schoolExternalTools.map( + (schoolExternalTool: SchoolExternalTool): string => + // We can be sure that the repo returns the id + schoolExternalTool.id as string + ); + const contextExternalToolCount: Record = { + [ContextExternalToolType.BOARD_ELEMENT]: 0, + [ContextExternalToolType.COURSE]: 0, + }; + if (schoolExternalTools.length >= 1) { + await Promise.all( + Object.values(ToolContextType).map(async (contextType: ToolContextType): Promise => { + const type: ContextExternalToolType = ToolContextMapper.contextMapping[contextType]; + + const countPerContext: number = await this.contextToolRepo.countBySchoolToolIdsAndContextType( + type, + schoolExternalToolIds + ); + contextExternalToolCount[type] = countPerContext; + }) + ); + } + + const externaltoolMetadata: ExternalToolMetadata = new ExternalToolMetadata({ + schoolExternalToolCount: schoolExternalTools.length, + contextExternalToolCountPerContext: contextExternalToolCount, + }); + + return externaltoolMetadata; + } +} diff --git a/apps/server/src/modules/tool/external-tool/service/index.ts b/apps/server/src/modules/tool/external-tool/service/index.ts index f2290ca8969..e2a936d158b 100644 --- a/apps/server/src/modules/tool/external-tool/service/index.ts +++ b/apps/server/src/modules/tool/external-tool/service/index.ts @@ -5,3 +5,4 @@ export * from './external-tool-validation.service'; export * from './external-tool-parameter-validation.service'; export * from './external-tool-configuration.service'; export * from './external-tool-logo.service'; +export * from './external-tool-metadata.service'; diff --git a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts index 2d371f47c9e..501eef61de9 100644 --- a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts +++ b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts @@ -1,18 +1,24 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; import { UnauthorizedException, UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { IFindOptions, Permission, SortOrder, User } from '@shared/domain'; +import { IFindOptions, Permission, Role, SortOrder, User } from '@shared/domain'; import { Page } from '@shared/domain/domainobject/page'; -import { setupEntities, userFactory } from '@shared/testing'; +import { roleFactory, setupEntities, userFactory } from '@shared/testing'; import { externalToolFactory, oauth2ToolConfigFactory, } from '@shared/testing/factory/domainobject/tool/external-tool.factory'; import { ICurrentUser } from '@modules/authentication'; -import { AuthorizationService } from '@modules/authorization'; +import { Action, AuthorizationService } from '@modules/authorization'; import { ExternalToolSearchQuery } from '../../common/interface'; -import { ExternalTool, Oauth2ToolConfig } from '../domain'; -import { ExternalToolLogoService, ExternalToolService, ExternalToolValidationService } from '../service'; +import { ExternalTool, ExternalToolMetadata, Oauth2ToolConfig } from '../domain'; +import { + ExternalToolLogoService, + ExternalToolMetadataService, + ExternalToolService, + ExternalToolValidationService, +} from '../service'; import { ExternalToolUpdate } from './dto'; import { ExternalToolUc } from './external-tool.uc'; @@ -25,6 +31,7 @@ describe('ExternalToolUc', () => { let authorizationService: DeepMocked; let toolValidationService: DeepMocked; let logoService: DeepMocked; + let externalToolMetadataService: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -48,6 +55,10 @@ describe('ExternalToolUc', () => { provide: ExternalToolLogoService, useValue: createMock(), }, + { + provide: ExternalToolMetadataService, + useValue: createMock(), + }, ], }).compile(); @@ -56,6 +67,7 @@ describe('ExternalToolUc', () => { authorizationService = module.get(AuthorizationService); toolValidationService = module.get(ExternalToolValidationService); logoService = module.get(ExternalToolLogoService); + externalToolMetadataService = module.get(ExternalToolMetadataService); }); afterAll(async () => { @@ -468,4 +480,109 @@ describe('ExternalToolUc', () => { expect(externalToolService.deleteExternalTool).toHaveBeenCalledWith(toolId); }); }); + + describe('getMetadataForExternalTool', () => { + describe('when authorize user', () => { + const setupMetadata = () => { + const toolId: string = new ObjectId().toHexString(); + const tool: ExternalTool = externalToolFactory.buildWithId({ id: toolId }, toolId); + + const role: Role = roleFactory.buildWithId({ permissions: [Permission.TOOL_ADMIN] }); + const user: User = userFactory.buildWithId({ roles: [role] }); + const currentUser: ICurrentUser = { userId: user.id } as ICurrentUser; + const context = { action: Action.read, requiredPermissions: [Permission.TOOL_ADMIN] }; + + externalToolService.findById.mockResolvedValue(tool); + authorizationService.getUserWithPermissions.mockResolvedValue(user); + + return { + user, + currentUser, + toolId, + tool, + context, + }; + }; + + it('get user with permissions', async () => { + const { toolId, user } = setupMetadata(); + + await uc.getMetadataForExternalTool(user.id, toolId); + + expect(authorizationService.getUserWithPermissions).toHaveBeenCalledWith(user.id); + }); + + it('should check that the user has TOOL_ADMIN permission', async () => { + const { user, tool } = setupMetadata(); + + await uc.getMetadataForExternalTool(user.id, tool.id!); + + expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [Permission.TOOL_ADMIN]); + }); + }); + + describe('when user has insufficient permission to get an metadata for external tool ', () => { + const setupMetadata = () => { + const toolId: string = new ObjectId().toHexString(); + + const user: User = userFactory.buildWithId(); + + authorizationService.getUserWithPermissions.mockRejectedValue(new UnauthorizedException()); + + return { + user, + toolId, + }; + }; + + it('should throw UnauthorizedException ', async () => { + const { toolId, user } = setupMetadata(); + + const result: Promise = uc.getMetadataForExternalTool(user.id, toolId); + + await expect(result).rejects.toThrow(UnauthorizedException); + }); + }); + + describe('when externalToolId is given', () => { + const setupMetadata = () => { + const toolId: string = new ObjectId().toHexString(); + + const externalToolMetadata: ExternalToolMetadata = new ExternalToolMetadata({ + schoolExternalToolCount: 2, + contextExternalToolCountPerContext: { course: 3, 'board-element': 3 }, + }); + + externalToolMetadataService.getMetadata.mockResolvedValue(externalToolMetadata); + + const user: User = userFactory.buildWithId(); + const currentUser: ICurrentUser = { userId: user.id } as ICurrentUser; + + authorizationService.getUserWithPermissions.mockResolvedValue(user); + + return { + user, + currentUser, + toolId, + externalToolMetadata, + }; + }; + + it('get metadata for external tool', async () => { + const { toolId, currentUser } = setupMetadata(); + + await uc.getMetadataForExternalTool(currentUser.userId, toolId); + + expect(externalToolMetadataService.getMetadata).toHaveBeenCalledWith(toolId); + }); + + it('return metadata of external tool', async () => { + const { toolId, currentUser, externalToolMetadata } = setupMetadata(); + + const result = await uc.getMetadataForExternalTool(currentUser.userId, toolId); + + expect(result).toEqual(externalToolMetadata); + }); + }); + }); }); diff --git a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.ts b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.ts index 2cf49867103..9880994272a 100644 --- a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.ts +++ b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.ts @@ -2,8 +2,13 @@ import { Injectable } from '@nestjs/common'; import { EntityId, IFindOptions, Page, Permission, User } from '@shared/domain'; import { AuthorizationService } from '@modules/authorization'; import { ExternalToolSearchQuery } from '../../common/interface'; -import { ExternalTool, ExternalToolConfig } from '../domain'; -import { ExternalToolLogoService, ExternalToolService, ExternalToolValidationService } from '../service'; +import { ExternalTool, ExternalToolConfig, ExternalToolMetadata } from '../domain'; +import { + ExternalToolLogoService, + ExternalToolService, + ExternalToolValidationService, + ExternalToolMetadataService, +} from '../service'; import { ExternalToolCreate, ExternalToolUpdate } from './dto'; @Injectable() @@ -12,7 +17,8 @@ export class ExternalToolUc { private readonly externalToolService: ExternalToolService, private readonly authorizationService: AuthorizationService, private readonly toolValidationService: ExternalToolValidationService, - private readonly externalToolLogoService: ExternalToolLogoService + private readonly externalToolLogoService: ExternalToolLogoService, + private readonly externalToolMetadataService: ExternalToolMetadataService ) {} async createExternalTool(userId: EntityId, externalToolCreate: ExternalToolCreate): Promise { @@ -74,6 +80,15 @@ export class ExternalToolUc { return promise; } + async getMetadataForExternalTool(userId: EntityId, toolId: EntityId): Promise { + // TODO N21-1496: Change External Tools to use authorizationService.checkPermission + await this.ensurePermission(userId, Permission.TOOL_ADMIN); + + const metadata: ExternalToolMetadata = await this.externalToolMetadataService.getMetadata(toolId); + + return metadata; + } + private async ensurePermission(userId: EntityId, permission: Permission) { const user: User = await this.authorizationService.getUserWithPermissions(userId); this.authorizationService.checkAllPermissions(user, [permission]); diff --git a/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts b/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts index 2cca9819b3a..274bd289edf 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts @@ -1,10 +1,12 @@ import { EntityManager, MikroORM } from '@mikro-orm/core'; +import { ObjectId } from '@mikro-orm/mongodb'; import { ServerTestModule } from '@modules/server'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Account, Permission, SchoolEntity, User } from '@shared/domain'; import { accountFactory, + contextExternalToolEntityFactory, customParameterEntityFactory, externalToolEntityFactory, schoolExternalToolEntityFactory, @@ -13,7 +15,8 @@ import { UserAndAccountTestFactory, userFactory, } from '@shared/testing'; -import { ToolConfigurationStatusResponse } from '../../../context-external-tool/controller/dto/tool-configuration-status.response'; +import { ToolConfigurationStatusResponse } from '../../../context-external-tool/controller/dto'; +import { ContextExternalToolEntity, ContextExternalToolType } from '../../../context-external-tool/entity'; import { CustomParameterScope, CustomParameterType, ExternalToolEntity } from '../../../external-tool/entity'; import { SchoolExternalToolEntity } from '../../entity'; import { @@ -22,6 +25,7 @@ import { SchoolExternalToolResponse, SchoolExternalToolSearchListResponse, SchoolExternalToolSearchParams, + SchoolExternalToolMetadataResponse, } from '../dto'; describe('ToolSchoolController (API)', () => { @@ -506,4 +510,81 @@ describe('ToolSchoolController (API)', () => { expect(updatedSchoolExternalTool).toBeDefined(); }); }); + + describe('[GET] tools/school-external-tools/:schoolExternalToolId/metadata', () => { + describe('when user is not authenticated', () => { + const setup = () => { + const toolId: string = new ObjectId().toHexString(); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(undefined, toolId); + + return { toolId, externalToolEntity }; + }; + + it('should return unauthorized', async () => { + const { externalToolEntity } = setup(); + + const response = await testApiClient.get(`${externalToolEntity.id}/metadata`); + + expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when schoolExternalToolId is given ', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + const schoolToolId: string = new ObjectId().toHexString(); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId( + { school }, + schoolToolId + ); + + const courseExternalToolEntitys: ContextExternalToolEntity[] = contextExternalToolEntityFactory.buildList(3, { + schoolTool: schoolExternalToolEntity, + contextType: ContextExternalToolType.COURSE, + contextId: new ObjectId().toHexString(), + }); + + const boardExternalToolEntitys: ContextExternalToolEntity[] = contextExternalToolEntityFactory.buildList(2, { + schoolTool: schoolExternalToolEntity, + contextType: ContextExternalToolType.BOARD_ELEMENT, + contextId: new ObjectId().toHexString(), + }); + + const schoolExternalToolMetadata: SchoolExternalToolMetadataResponse = new SchoolExternalToolMetadataResponse({ + contextExternalToolCountPerContext: { course: 3, boardElement: 2 }, + }); + + const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school }, [ + Permission.SCHOOL_TOOL_ADMIN, + ]); + + await em.persistAndFlush([ + adminAccount, + adminUser, + schoolExternalToolEntity, + ...courseExternalToolEntitys, + ...boardExternalToolEntitys, + ]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); + + return { loggedInClient, schoolExternalToolEntity, schoolExternalToolMetadata }; + }; + + it('should return the metadata of schoolExternalTool', async () => { + const { loggedInClient, schoolExternalToolEntity } = await setup(); + + const response = await loggedInClient.get(`${schoolExternalToolEntity.id}/metadata`); + + expect(response.statusCode).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + contextExternalToolCountPerContext: { + course: 3, + boardElement: 2, + }, + }); + }); + }); + }); }); diff --git a/apps/server/src/modules/tool/school-external-tool/controller/dto/index.ts b/apps/server/src/modules/tool/school-external-tool/controller/dto/index.ts index 5c3075e1cd2..595b16d7f9c 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/dto/index.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/dto/index.ts @@ -5,3 +5,4 @@ export * from './custom-parameter-entry.response'; export * from './school-external-tool-post.params'; export * from './school-external-tool-search.params'; export * from './school-external-tool-search-list.response'; +export * from './school-external-tool-metadata.response'; diff --git a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-metadata.response.ts b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-metadata.response.ts new file mode 100644 index 00000000000..db4806d23ec --- /dev/null +++ b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-metadata.response.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ContextExternalToolType } from '../../../context-external-tool/entity'; + +export class SchoolExternalToolMetadataResponse { + @ApiProperty({ + type: 'object', + properties: Object.fromEntries( + Object.values(ContextExternalToolType).map((key: ContextExternalToolType) => [key, { type: 'number' }]) + ), + }) + contextExternalToolCountPerContext: Record; + + constructor(schoolExternalToolMetadataResponse: SchoolExternalToolMetadataResponse) { + this.contextExternalToolCountPerContext = schoolExternalToolMetadataResponse.contextExternalToolCountPerContext; + } +} diff --git a/apps/server/src/modules/tool/school-external-tool/controller/tool-school.controller.ts b/apps/server/src/modules/tool/school-external-tool/controller/tool-school.controller.ts index 79d6f789443..ccfef902bee 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/tool-school.controller.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/tool-school.controller.ts @@ -14,7 +14,11 @@ import { Body, Controller, Delete, Get, Param, Post, Query, Put, HttpCode, HttpS import { ValidationError } from '@shared/common'; import { LegacyLogger } from '@src/core/logger'; import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; -import { SchoolExternalToolRequestMapper, SchoolExternalToolResponseMapper } from '../mapper'; +import { + SchoolExternalToolRequestMapper, + SchoolExternalToolResponseMapper, + SchoolExternalToolMetadataMapper, +} from '../mapper'; import { ExternalToolSearchListResponse } from '../../external-tool/controller/dto'; import { SchoolExternalToolIdParams, @@ -22,10 +26,11 @@ import { SchoolExternalToolResponse, SchoolExternalToolSearchListResponse, SchoolExternalToolSearchParams, + SchoolExternalToolMetadataResponse, } from './dto'; import { SchoolExternalToolDto } from '../uc/dto/school-external-tool.types'; import { SchoolExternalToolUc } from '../uc'; -import { SchoolExternalTool } from '../domain'; +import { SchoolExternalTool, SchoolExternalToolMetadata } from '../domain'; @ApiTags('Tool') @Authenticate('jwt') @@ -136,4 +141,24 @@ export class ToolSchoolController { return response; } + + @Get('/:schoolExternalToolId/metadata') + @ApiOperation({ summary: 'Gets the metadata of an school external tool.' }) + @ApiOkResponse({ + description: 'Metadata of school external tool fetched successfully.', + type: SchoolExternalToolMetadataResponse, + }) + @ApiUnauthorizedResponse({ description: 'User is not logged in.' }) + async getMetaDataForExternalTool( + @CurrentUser() currentUser: ICurrentUser, + @Param() params: SchoolExternalToolIdParams + ): Promise { + const schoolExternalToolMetadata: SchoolExternalToolMetadata = + await this.schoolExternalToolUc.getMetadataForSchoolExternalTool(currentUser.userId, params.schoolExternalToolId); + + const mapped: SchoolExternalToolMetadataResponse = + SchoolExternalToolMetadataMapper.mapToSchoolExternalToolMetadataResponse(schoolExternalToolMetadata); + + return mapped; + } } diff --git a/apps/server/src/modules/tool/school-external-tool/domain/index.ts b/apps/server/src/modules/tool/school-external-tool/domain/index.ts index 1d734ed8376..d089fb2e908 100644 --- a/apps/server/src/modules/tool/school-external-tool/domain/index.ts +++ b/apps/server/src/modules/tool/school-external-tool/domain/index.ts @@ -1,2 +1,3 @@ export * from './school-external-tool.do'; export * from './school-external-tool-ref.do'; +export * from './school-external-tool-metadata'; diff --git a/apps/server/src/modules/tool/school-external-tool/domain/school-external-tool-metadata.ts b/apps/server/src/modules/tool/school-external-tool/domain/school-external-tool-metadata.ts new file mode 100644 index 00000000000..4cccdfe11a1 --- /dev/null +++ b/apps/server/src/modules/tool/school-external-tool/domain/school-external-tool-metadata.ts @@ -0,0 +1,7 @@ +export class SchoolExternalToolMetadata { + contextExternalToolCountPerContext: Record; + + constructor(schoolExternalToolMetadata: SchoolExternalToolMetadata) { + this.contextExternalToolCountPerContext = schoolExternalToolMetadata.contextExternalToolCountPerContext; + } +} diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/index.ts b/apps/server/src/modules/tool/school-external-tool/mapper/index.ts index 961644805f1..48cd0b13a20 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/index.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/index.ts @@ -1,2 +1,3 @@ export * from './school-external-tool-request.mapper'; export * from './school-external-tool-response.mapper'; +export * from './school-external-tool-metadata.mapper'; diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-metadata.mapper.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-metadata.mapper.ts new file mode 100644 index 00000000000..1e42f22ebf2 --- /dev/null +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-metadata.mapper.ts @@ -0,0 +1,14 @@ +import { SchoolExternalToolMetadataResponse } from '../controller/dto'; +import { SchoolExternalToolMetadata } from '../domain'; + +export class SchoolExternalToolMetadataMapper { + static mapToSchoolExternalToolMetadataResponse( + schoolExternalToolMetadata: SchoolExternalToolMetadata + ): SchoolExternalToolMetadataResponse { + const externalToolMetadataResponse: SchoolExternalToolMetadataResponse = new SchoolExternalToolMetadataResponse({ + contextExternalToolCountPerContext: schoolExternalToolMetadata.contextExternalToolCountPerContext, + }); + + return externalToolMetadataResponse; + } +} diff --git a/apps/server/src/modules/tool/school-external-tool/school-external-tool.module.ts b/apps/server/src/modules/tool/school-external-tool/school-external-tool.module.ts index 898e159348a..93d4c4f6705 100644 --- a/apps/server/src/modules/tool/school-external-tool/school-external-tool.module.ts +++ b/apps/server/src/modules/tool/school-external-tool/school-external-tool.module.ts @@ -1,12 +1,16 @@ import { Module } from '@nestjs/common'; import { CommonToolModule } from '../common'; -import { SchoolExternalToolService, SchoolExternalToolValidationService } from './service'; +import { + SchoolExternalToolService, + SchoolExternalToolValidationService, + SchoolExternalToolMetadataService, +} from './service'; import { ExternalToolModule } from '../external-tool'; import { ToolConfigModule } from '../tool-config.module'; @Module({ imports: [CommonToolModule, ExternalToolModule, ToolConfigModule], - providers: [SchoolExternalToolService, SchoolExternalToolValidationService], - exports: [SchoolExternalToolService, SchoolExternalToolValidationService], + providers: [SchoolExternalToolService, SchoolExternalToolValidationService, SchoolExternalToolMetadataService], + exports: [SchoolExternalToolService, SchoolExternalToolValidationService, SchoolExternalToolMetadataService], }) export class SchoolExternalToolModule {} diff --git a/apps/server/src/modules/tool/school-external-tool/service/index.ts b/apps/server/src/modules/tool/school-external-tool/service/index.ts index 1ceab5f3da5..ea949d8b70a 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/index.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/index.ts @@ -1,2 +1,3 @@ export * from './school-external-tool.service'; export * from './school-external-tool-validation.service'; +export * from './school-external-tool-metadata.service'; diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-metadata.service.spec.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-metadata.service.spec.ts new file mode 100644 index 00000000000..8aa29737550 --- /dev/null +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-metadata.service.spec.ts @@ -0,0 +1,93 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ContextExternalToolRepo } from '@shared/repo'; +import { Logger } from '@src/core/logger'; +import { SchoolExternalToolMetadata } from '../domain'; +import { SchoolExternalToolMetadataService } from './school-external-tool-metadata.service'; + +describe('SchoolExternalToolMetadataService', () => { + let module: TestingModule; + let service: SchoolExternalToolMetadataService; + + let contextExternalToolRepo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + SchoolExternalToolMetadataService, + { + provide: ContextExternalToolRepo, + useValue: createMock(), + }, + { + provide: Logger, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(SchoolExternalToolMetadataService); + contextExternalToolRepo = module.get(ContextExternalToolRepo); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getMetadata', () => { + describe('when schoolExternalToolId is given', () => { + const setup = () => { + const schoolToolId: string = new ObjectId().toHexString(); + + const schoolExternalToolMetadata: SchoolExternalToolMetadata = new SchoolExternalToolMetadata({ + contextExternalToolCountPerContext: { course: 3, boardElement: 3 }, + }); + + contextExternalToolRepo.countBySchoolToolIdsAndContextType.mockResolvedValue(3); + + return { + schoolToolId, + schoolExternalToolMetadata, + }; + }; + + it('should return externalToolMetadata', async () => { + const { schoolToolId, schoolExternalToolMetadata } = setup(); + + const result: SchoolExternalToolMetadata = await service.getMetadata(schoolToolId); + + expect(result).toEqual(schoolExternalToolMetadata); + }); + }); + + describe('when no related context external tool was found', () => { + const setup = () => { + const schoolToolId: string = new ObjectId().toHexString(); + + const schoolExternalToolMetadata: SchoolExternalToolMetadata = new SchoolExternalToolMetadata({ + contextExternalToolCountPerContext: { course: 0, boardElement: 0 }, + }); + + contextExternalToolRepo.countBySchoolToolIdsAndContextType.mockResolvedValue(0); + + return { + schoolToolId, + schoolExternalToolMetadata, + }; + }; + + it('should return empty schoolExternalToolMetadata', async () => { + const { schoolToolId, schoolExternalToolMetadata } = setup(); + + const result: SchoolExternalToolMetadata = await service.getMetadata(schoolToolId); + + expect(result).toEqual(schoolExternalToolMetadata); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-metadata.service.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-metadata.service.ts new file mode 100644 index 00000000000..fa8fd4fc926 --- /dev/null +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-metadata.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { ContextExternalToolRepo } from '@shared/repo'; +import { ToolContextType } from '../../common/enum'; +import { ToolContextMapper } from '../../common/mapper/tool-context.mapper'; +import { ContextExternalToolType } from '../../context-external-tool/entity'; +import { SchoolExternalToolMetadata } from '../domain'; + +@Injectable() +export class SchoolExternalToolMetadataService { + constructor(private readonly contextToolRepo: ContextExternalToolRepo) {} + + async getMetadata(schoolExternalToolId: EntityId) { + const contextExternalToolCount: Record = { + [ContextExternalToolType.BOARD_ELEMENT]: 0, + [ContextExternalToolType.COURSE]: 0, + }; + + await Promise.all( + Object.values(ToolContextType).map(async (contextType: ToolContextType): Promise => { + const type: ContextExternalToolType = ToolContextMapper.contextMapping[contextType]; + + const countPerContext: number = await this.contextToolRepo.countBySchoolToolIdsAndContextType(type, [ + schoolExternalToolId, + ]); + + contextExternalToolCount[type] = countPerContext; + }) + ); + + const schoolExternaltoolMetadata: SchoolExternalToolMetadata = new SchoolExternalToolMetadata({ + contextExternalToolCountPerContext: contextExternalToolCount, + }); + + return schoolExternaltoolMetadata; + } +} diff --git a/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.spec.ts b/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.spec.ts index 377a5d24a38..6f3512f6a0d 100644 --- a/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.spec.ts @@ -1,4 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityId, Permission, User } from '@shared/domain'; import { schoolExternalToolFactory, setupEntities, userFactory } from '@shared/testing'; @@ -6,7 +7,11 @@ import { AuthorizationContextBuilder } from '@modules/authorization'; import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; import { ContextExternalToolService } from '../../context-external-tool/service'; import { SchoolExternalTool } from '../domain'; -import { SchoolExternalToolService, SchoolExternalToolValidationService } from '../service'; +import { + SchoolExternalToolMetadataService, + SchoolExternalToolService, + SchoolExternalToolValidationService, +} from '../service'; import { SchoolExternalToolQueryInput } from './dto/school-external-tool.types'; import { SchoolExternalToolUc } from './school-external-tool.uc'; @@ -18,6 +23,7 @@ describe('SchoolExternalToolUc', () => { let contextExternalToolService: DeepMocked; let schoolExternalToolValidationService: DeepMocked; let toolPermissionHelper: DeepMocked; + let schoolExternalToolMetadataService: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -40,6 +46,10 @@ describe('SchoolExternalToolUc', () => { provide: ToolPermissionHelper, useValue: createMock(), }, + { + provide: SchoolExternalToolMetadataService, + useValue: createMock(), + }, ], }).compile(); @@ -48,6 +58,7 @@ describe('SchoolExternalToolUc', () => { contextExternalToolService = module.get(ContextExternalToolService); schoolExternalToolValidationService = module.get(SchoolExternalToolValidationService); toolPermissionHelper = module.get(ToolPermissionHelper); + schoolExternalToolMetadataService = module.get(SchoolExternalToolMetadataService); }); afterAll(async () => { @@ -358,4 +369,55 @@ describe('SchoolExternalToolUc', () => { expect(result).toEqual(updatedTool); }); }); + + describe('getMetadataForSchoolExternalTool', () => { + describe('when authorize user', () => { + const setupMetadata = () => { + const toolId = new ObjectId().toHexString(); + const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ id: toolId }, toolId); + const userId: string = new ObjectId().toHexString(); + const user: User = userFactory.buildWithId({}, userId); + + schoolExternalToolService.findById.mockResolvedValue(tool); + + return { + user, + tool, + }; + }; + + it('should check the permissions of the user', async () => { + const { user, tool } = setupMetadata(); + + await uc.getMetadataForSchoolExternalTool(user.id, tool.id!); + + expect(toolPermissionHelper.ensureSchoolPermissions).toHaveBeenCalledWith( + user.id, + tool, + AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]) + ); + }); + }); + + describe('when externalToolId is given', () => { + const setupMetadata = () => { + const user: User = userFactory.buildWithId(); + + const toolId: string = new ObjectId().toHexString(); + + return { + toolId, + user, + }; + }; + + it('should call the service to get metadata', async () => { + const { toolId, user } = setupMetadata(); + + await uc.getMetadataForSchoolExternalTool(user.id, toolId); + + expect(schoolExternalToolMetadataService.getMetadata).toHaveBeenCalledWith(toolId); + }); + }); + }); }); diff --git a/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.ts b/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.ts index d7adf3f4937..0d098c8c657 100644 --- a/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.ts +++ b/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.ts @@ -3,8 +3,12 @@ import { EntityId, Permission } from '@shared/domain'; import { AuthorizationContext, AuthorizationContextBuilder } from '@modules/authorization'; import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; import { ContextExternalToolService } from '../../context-external-tool/service'; -import { SchoolExternalTool } from '../domain'; -import { SchoolExternalToolService, SchoolExternalToolValidationService } from '../service'; +import { SchoolExternalTool, SchoolExternalToolMetadata } from '../domain'; +import { + SchoolExternalToolService, + SchoolExternalToolValidationService, + SchoolExternalToolMetadataService, +} from '../service'; import { SchoolExternalToolDto, SchoolExternalToolQueryInput } from './dto/school-external-tool.types'; @Injectable() @@ -13,6 +17,7 @@ export class SchoolExternalToolUc { private readonly schoolExternalToolService: SchoolExternalToolService, private readonly contextExternalToolService: ContextExternalToolService, private readonly schoolExternalToolValidationService: SchoolExternalToolValidationService, + private readonly schoolExternalToolMetadataService: SchoolExternalToolMetadataService, private readonly toolPermissionHelper: ToolPermissionHelper ) {} @@ -95,4 +100,20 @@ export class SchoolExternalToolUc { const saved = await this.schoolExternalToolService.saveSchoolExternalTool(updated); return saved; } + + async getMetadataForSchoolExternalTool( + userId: EntityId, + schoolExternalToolId: EntityId + ): Promise { + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById(schoolExternalToolId); + + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); + await this.toolPermissionHelper.ensureSchoolPermissions(userId, schoolExternalTool, context); + + const metadata: SchoolExternalToolMetadata = await this.schoolExternalToolMetadataService.getMetadata( + schoolExternalToolId + ); + + return metadata; + } } diff --git a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.integration.spec.ts b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.integration.spec.ts index 854c3958135..c6a684a377d 100644 --- a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.integration.spec.ts @@ -1,8 +1,14 @@ import { createMock } from '@golevelup/ts-jest'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { CustomParameterEntry } from '@modules/tool/common/domain'; +import { ToolContextType } from '@modules/tool/common/enum'; +import { ContextExternalTool, ContextExternalToolProps } from '@modules/tool/context-external-tool/domain'; +import { ContextExternalToolEntity, ContextExternalToolType } from '@modules/tool/context-external-tool/entity'; +import { ContextExternalToolQuery } from '@modules/tool/context-external-tool/uc/dto/context-external-tool.types'; +import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; import { Test, TestingModule } from '@nestjs/testing'; import { SchoolEntity } from '@shared/domain'; -import { MongoMemoryDatabaseModule } from '@infra/database'; import { ExternalToolRepoMapper } from '@shared/repo/externaltool/external-tool.repo.mapper'; import { cleanupCollections, @@ -12,12 +18,6 @@ import { schoolFactory, } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { CustomParameterEntry } from '@modules/tool/common/domain'; -import { ToolContextType } from '@modules/tool/common/enum'; -import { ContextExternalTool, ContextExternalToolProps } from '@modules/tool/context-external-tool/domain'; -import { ContextExternalToolEntity, ContextExternalToolType } from '@modules/tool/context-external-tool/entity'; -import { ContextExternalToolQuery } from '@modules/tool/context-external-tool/uc/dto/context-external-tool.types'; -import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; import { ContextExternalToolRepo } from './context-external-tool.repo'; describe('ContextExternalToolRepo', () => { @@ -394,4 +394,86 @@ describe('ContextExternalToolRepo', () => { }); }); }); + + describe('countBySchoolToolIdsAndContextType', () => { + describe('when a ContextExternalTool is found for course context', () => { + const setup = async () => { + const schoolExternalTool = schoolExternalToolEntityFactory.buildWithId(); + const schoolExternalTool1 = schoolExternalToolEntityFactory.buildWithId(); + + const contextExternalTool = contextExternalToolEntityFactory.buildList(4, { + contextType: ContextExternalToolType.COURSE, + schoolTool: schoolExternalTool, + }); + + const contextExternalTool3 = contextExternalToolEntityFactory.buildList(2, { + contextType: ContextExternalToolType.COURSE, + schoolTool: schoolExternalTool1, + }); + + await em.persistAndFlush([ + schoolExternalTool, + schoolExternalTool1, + ...contextExternalTool, + ...contextExternalTool3, + ]); + + return { + schoolExternalTool, + schoolExternalTool1, + }; + }; + + it('should return correct results', async () => { + const { schoolExternalTool, schoolExternalTool1 } = await setup(); + + const result = await repo.countBySchoolToolIdsAndContextType(ContextExternalToolType.COURSE, [ + schoolExternalTool.id, + schoolExternalTool1.id, + ]); + + expect(result).toEqual(6); + }); + }); + + describe('when a ContextExternalTool is found for board context', () => { + const setup = async () => { + const schoolExternalTool = schoolExternalToolEntityFactory.buildWithId(); + const schoolExternalTool1 = schoolExternalToolEntityFactory.buildWithId(); + + const contextExternalTool1 = contextExternalToolEntityFactory.buildList(3, { + contextType: ContextExternalToolType.BOARD_ELEMENT, + schoolTool: schoolExternalTool, + }); + + const contextExternalTool2 = contextExternalToolEntityFactory.buildList(2, { + contextType: ContextExternalToolType.BOARD_ELEMENT, + schoolTool: schoolExternalTool1, + }); + + await em.persistAndFlush([ + schoolExternalTool, + schoolExternalTool1, + ...contextExternalTool1, + ...contextExternalTool2, + ]); + + return { + schoolExternalTool, + schoolExternalTool1, + }; + }; + + it('should return correct results', async () => { + const { schoolExternalTool, schoolExternalTool1 } = await setup(); + + const result = await repo.countBySchoolToolIdsAndContextType(ContextExternalToolType.BOARD_ELEMENT, [ + schoolExternalTool.id, + schoolExternalTool1.id, + ]); + + expect(result).toEqual(5); + }); + }); + }); }); diff --git a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts index 5ad1629f0c2..31053cd48b5 100644 --- a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts +++ b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts @@ -53,6 +53,14 @@ export class ContextExternalToolRepo extends BaseDORepo< return dos; } + async countBySchoolToolIdsAndContextType(contextType: ContextExternalToolType, schoolExternalToolIds: string[]) { + const contextExternalToolCount = await this._em.count(this.entityName, { + $and: [{ schoolTool: { $in: schoolExternalToolIds }, contextType }], + }); + + return contextExternalToolCount; + } + public override async findById(id: EntityId): Promise { const entity: ContextExternalToolEntity = await this._em.findOneOrFail( this.entityName, diff --git a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.ts b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.ts index 64f55c715e8..c17e4d2ac17 100644 --- a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.ts +++ b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.ts @@ -44,6 +44,7 @@ export class SchoolExternalToolRepo extends BaseDORepo< const domainObject: SchoolExternalTool = this.mapEntityToDO(entity); return domainObject; }); + return domainObjects; } diff --git a/backup/setup/school-external-tools.json b/backup/setup/school-external-tools.json index 884c8ffe373..59befc464af 100644 --- a/backup/setup/school-external-tools.json +++ b/backup/setup/school-external-tools.json @@ -45,5 +45,28 @@ }, "schoolParameters": [], "toolVersion": 2 + }, + { + "_id": { + "$oid": "647de374cf6a427b9d39e5bb" + }, + "createdAt": { + "$date": { + "$numberLong": "1685971828284" + } + }, + "updatedAt": { + "$date": { + "$numberLong": "1685971828284" + } + }, + "tool": { + "$oid": "647de247cf6a427b9d39e5b9" + }, + "school": { + "$oid": "5fa2c5ccb229544f2c69666c" + }, + "schoolParameters": [], + "toolVersion": 2 } ]