diff --git a/apps/server/src/migrations/mikro-orm/Migration20241120100616.ts b/apps/server/src/migrations/mikro-orm/Migration20241120100616.ts new file mode 100644 index 0000000000..1080f7eb2f --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20241120100616.ts @@ -0,0 +1,65 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; +import { ObjectId } from '@mikro-orm/mongodb'; + +export class Migration20241120100616 extends Migration { + async up(): Promise { + const cursor = this.getCollection<{ contextType: string; contextId: ObjectId; schoolTool: ObjectId }>( + 'context-external-tools' + ).find({ + $or: [{ contextType: 'course' }, { contextType: 'boardElement' }], + }); + + let numberOfDeletedTools = 0; + let numberOfDeletedElements = 0; + for await (const tool of cursor) { + let courseId: ObjectId | undefined; + if (tool.contextType === 'course') { + courseId = tool.contextId; + } else if (tool.contextType === 'boardElement') { + const element = await this.getCollection<{ path: string }>('boardnodes').findOne({ + _id: tool.contextId, + }); + + if (element) { + const boardId = new ObjectId(element.path.split(',')[1]); + + const board = await this.getCollection<{ context: ObjectId }>('boardnodes').findOne({ + _id: boardId, + }); + + if (board) { + courseId = board.context; + } + } + } + + if (courseId) { + const course = await this.getCollection<{ schoolId: ObjectId }>('courses').findOne({ _id: courseId }); + + const schoolTool = await this.getCollection<{ school: ObjectId }>('school-external-tools').findOne({ + _id: tool.schoolTool, + }); + + if (!course || !schoolTool || course.schoolId.toString() !== schoolTool.school.toString()) { + await this.driver.nativeDelete('context-external-tools', { _id: tool._id }); + console.info(`deleted context external tool: ${tool._id.toString()}`); + numberOfDeletedTools += 1; + if (tool.contextType === 'boardElement') { + await this.driver.nativeDelete('boardnodes', { _id: tool.contextId }); + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + console.info(`deleted boardnode: ${tool.contextId}`); + numberOfDeletedElements += 1; + } + } + } + } + console.info( + `Deleted ${numberOfDeletedTools} context external tools and ${numberOfDeletedElements} external tool elements.` + ); + } + + // eslint-disable-next-line @typescript-eslint/require-await + async down(): Promise { + console.info('Unfortunately the deleted documents cannot be restored. Use a backup.'); + } +} diff --git a/apps/server/src/modules/board/controller/board.controller.ts b/apps/server/src/modules/board/controller/board.controller.ts index 120ce0863d..2a6f4e74cf 100644 --- a/apps/server/src/modules/board/controller/board.controller.ts +++ b/apps/server/src/modules/board/controller/board.controller.ts @@ -138,7 +138,7 @@ export class BoardController { @Param() urlParams: BoardUrlParams, @CurrentUser() currentUser: ICurrentUser ): Promise { - const copyStatus = await this.boardUc.copyBoard(currentUser.userId, urlParams.boardId); + const copyStatus = await this.boardUc.copyBoard(currentUser.userId, urlParams.boardId, currentUser.schoolId); const dto = CopyMapper.mapToResponse(copyStatus); return dto; } diff --git a/apps/server/src/modules/board/service/column-board.service.spec.ts b/apps/server/src/modules/board/service/column-board.service.spec.ts index ac2986ff47..5b0da8b87f 100644 --- a/apps/server/src/modules/board/service/column-board.service.spec.ts +++ b/apps/server/src/modules/board/service/column-board.service.spec.ts @@ -2,6 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityId } from '@shared/domain/types'; import { StorageLocation } from '@src/modules/files-storage/interface'; +import { ObjectId } from '@mikro-orm/mongodb'; import { CopyElementType, CopyStatus, CopyStatusEnum } from '../../copy-helper'; import { BoardExternalReference, BoardExternalReferenceType, ColumnBoard } from '../domain'; import { BoardNodeRepo } from '../repo'; @@ -114,6 +115,7 @@ describe('ColumnBoardService', () => { sourceStorageLocationReference: { id: '1', type: StorageLocation.SCHOOL }, targetStorageLocationReference: { id: '1', type: StorageLocation.SCHOOL }, userId: '1', + targetSchoolId: new ObjectId().toHexString(), }); expect(result).toEqual(copyStatus); diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-context.spec.ts b/apps/server/src/modules/board/service/internal/board-node-copy-context.spec.ts index a65ef01e65..c2e917b70c 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-context.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-context.spec.ts @@ -13,6 +13,7 @@ describe(BoardNodeCopyContext.name, () => { targetStorageLocationReference: { id: new ObjectId().toHexString(), type: StorageLocation.SCHOOL }, userId: new ObjectId().toHexString(), filesStorageClientAdapterService: createMock(), + targetSchoolId: new ObjectId().toHexString(), }; const copyContext = new BoardNodeCopyContext(contextProps); diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-context.ts b/apps/server/src/modules/board/service/internal/board-node-copy-context.ts index f32fe23253..d442cc35b7 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-context.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-context.ts @@ -15,10 +15,15 @@ export type BoardNodeCopyContextProps = { targetStorageLocationReference: StorageLocationReference; userId: EntityId; filesStorageClientAdapterService: FilesStorageClientAdapterService; + targetSchoolId: EntityId; }; export class BoardNodeCopyContext implements CopyContext { - constructor(private readonly props: BoardNodeCopyContextProps) {} + readonly targetSchoolId: EntityId; + + constructor(private readonly props: BoardNodeCopyContextProps) { + this.targetSchoolId = props.targetSchoolId; + } copyFilesOfParent(sourceParentId: EntityId, targetParentId: EntityId): Promise { return this.props.filesStorageClientAdapterService.copyFilesOfParent({ diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts b/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts index 71ea6f8ab5..2a3b6b4c8f 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts @@ -2,6 +2,7 @@ import { createMock } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; import { StorageLocation } from '@modules/files-storage/interface'; +import { SchoolExternalToolService } from '@modules/tool/school-external-tool/service'; import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; import { ToolConfig } from '@modules/tool/tool-config'; import { ConfigService } from '@nestjs/config'; @@ -48,6 +49,10 @@ describe(BoardNodeCopyService.name, () => { provide: CopyHelperService, useValue: createMock(), }, + { + provide: SchoolExternalToolService, + useValue: createMock(), + }, ], }).compile(); @@ -70,6 +75,7 @@ describe(BoardNodeCopyService.name, () => { targetStorageLocationReference: { id: new ObjectId().toHexString(), type: StorageLocation.SCHOOL }, userId: new ObjectId().toHexString(), filesStorageClientAdapterService: createMock(), + targetSchoolId: new ObjectId().toHexString(), }; const copyContext = new BoardNodeCopyContext(contextProps); diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts b/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts index 44de4316a5..a2400688f7 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts @@ -4,12 +4,15 @@ import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from ' import { StorageLocation } from '@modules/files-storage/interface'; import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; import { ToolConfig } from '@modules/tool/tool-config'; +import { copyContextExternalToolRejectDataFactory } from '@modules/tool/context-external-tool/testing'; +import { CopyContextExternalToolRejectData } from '@modules/tool/context-external-tool/domain'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { setupEntities } from '@shared/testing'; import { FilesStorageClientAdapterService } from '@src/modules/files-storage-client'; import { CopyFileDto } from '@src/modules/files-storage-client/dto'; import { contextExternalToolFactory } from '@src/modules/tool/context-external-tool/testing'; + import { Card, CollaborativeTextEditorElement, @@ -103,6 +106,7 @@ describe(BoardNodeCopyService.name, () => { targetStorageLocationReference: { id: new ObjectId().toHexString(), type: StorageLocation.SCHOOL }, userId: new ObjectId().toHexString(), filesStorageClientAdapterService: createMock(), + targetSchoolId: new ObjectId().toHexString(), }; const copyContext = new BoardNodeCopyContext(contextProps); @@ -420,23 +424,68 @@ describe(BoardNodeCopyService.name, () => { const setupToolElement = () => { const { copyContext, externalToolElement } = setupCopyEnabled(); - const tool = contextExternalToolFactory.build(); - const toolCopy = contextExternalToolFactory.build(); - contextExternalToolService.findById.mockResolvedValueOnce(tool); - contextExternalToolService.copyContextExternalTool.mockResolvedValueOnce(toolCopy); - externalToolElement.contextExternalToolId = tool.id; + const contextTool = contextExternalToolFactory.build(); + contextExternalToolService.findById.mockResolvedValueOnce(contextTool); + externalToolElement.contextExternalToolId = contextTool.id; - return { copyContext, externalToolElement, tool, toolCopy }; + return { copyContext, externalToolElement, contextTool }; }; - it('should copy the external tool', async () => { - const { copyContext, externalToolElement, tool, toolCopy } = setupToolElement(); + describe('when the copying of context external tool is successful', () => { + const setupCopySuccess = () => { + const { copyContext, externalToolElement, contextTool } = setupToolElement(); - const result = await service.copyExternalToolElement(externalToolElement, copyContext); + const copiedTool = contextExternalToolFactory.build(); + contextExternalToolService.copyContextExternalTool.mockResolvedValue(copiedTool); + + return { copyContext, externalToolElement, contextTool, copiedTool }; + }; + + it('should return the copied entity as ExternalTool', async () => { + const { copyContext, externalToolElement, contextTool, copiedTool } = setupCopySuccess(); + + const result = await service.copyExternalToolElement(externalToolElement, copyContext); + + expect(contextExternalToolService.copyContextExternalTool).toHaveBeenCalledWith( + contextTool, + result.copyEntity?.id, + copyContext.targetSchoolId + ); + expect(result.copyEntity instanceof ExternalToolElement).toEqual(true); + expect((result.copyEntity as ExternalToolElement).contextExternalToolId).toEqual(copiedTool.id); + expect(result.type).toEqual(CopyElementType.EXTERNAL_TOOL_ELEMENT); + expect(result.status).toEqual(CopyStatusEnum.SUCCESS); + }); + }); - expect(contextExternalToolService.findById).toHaveBeenCalledWith(tool.id); - expect(contextExternalToolService.copyContextExternalTool).toHaveBeenCalledWith(tool, result.copyEntity?.id); - expect((result.copyEntity as ExternalToolElement).contextExternalToolId).toEqual(toolCopy.id); + describe('when the copying of context external tool is rejected', () => { + const setupCopyRejected = () => { + const { copyContext, externalToolElement, contextTool } = setupToolElement(); + + const copyRejectData = copyContextExternalToolRejectDataFactory.build(); + const mockWithCorrectType = Object.create( + CopyContextExternalToolRejectData.prototype + ) as CopyContextExternalToolRejectData; + Object.assign(mockWithCorrectType, { ...copyRejectData }); + contextExternalToolService.copyContextExternalTool.mockResolvedValue(mockWithCorrectType); + + return { copyContext, externalToolElement, contextTool }; + }; + + it('should return the copied entity as DeletedElement', async () => { + const { externalToolElement, copyContext, contextTool } = setupCopyRejected(); + + const result = await service.copyExternalToolElement(externalToolElement, copyContext); + + expect(contextExternalToolService.copyContextExternalTool).toHaveBeenCalledWith( + contextTool, + expect.any(String), + copyContext.targetSchoolId + ); + expect(result.copyEntity instanceof DeletedElement).toEqual(true); + expect(result.type).toEqual(CopyElementType.EXTERNAL_TOOL_ELEMENT); + expect(result.status).toEqual(CopyStatusEnum.FAIL); + }); }); }); diff --git a/apps/server/src/modules/board/service/internal/board-node-copy.service.ts b/apps/server/src/modules/board/service/internal/board-node-copy.service.ts index 3870411bac..1f8fcc1e4a 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy.service.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy.service.ts @@ -2,7 +2,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; import { CopyFileDto } from '@modules/files-storage-client/dto'; import { ContextExternalToolService } from '@modules/tool/context-external-tool'; -import { ContextExternalTool } from '@modules/tool/context-external-tool/domain'; +import { ContextExternalTool, CopyContextExternalToolRejectData } from '@modules/tool/context-external-tool/domain'; import { ToolConfig } from '@modules/tool/tool-config'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -14,6 +14,7 @@ import { CollaborativeTextEditorElement, Column, ColumnBoard, + ContentElementType, DeletedElement, DrawingElement, ExternalToolElement, @@ -30,6 +31,7 @@ import { } from '../../domain'; export interface CopyContext { + targetSchoolId: EntityId; copyFilesOfParent(sourceParentId: EntityId, targetParentId: EntityId): Promise; } @@ -283,35 +285,60 @@ export class BoardNodeCopyService { return Promise.resolve(result); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars async copyExternalToolElement(original: ExternalToolElement, context: CopyContext): Promise { - const copy = new ExternalToolElement({ + let copy: ExternalToolElement | DeletedElement; + copy = new ExternalToolElement({ ...original.getProps(), ...this.buildSpecificProps([]), }); - let status: CopyStatusEnum; - if (this.configService.get('FEATURE_CTL_TOOLS_COPY_ENABLED') && original.contextExternalToolId) { - const linkedTool = await this.contextExternalToolService.findById(original.contextExternalToolId); + if (!this.configService.get('FEATURE_CTL_TOOLS_COPY_ENABLED') || !original.contextExternalToolId) { + const copyStatus: CopyStatus = { + copyEntity: copy, + type: CopyElementType.EXTERNAL_TOOL_ELEMENT, + status: CopyStatusEnum.SUCCESS, + }; - if (linkedTool) { - const contextExternalToolCopy: ContextExternalTool = - await this.contextExternalToolService.copyContextExternalTool(linkedTool, copy.id); + return Promise.resolve(copyStatus); + } - copy.contextExternalToolId = contextExternalToolCopy.id; + const linkedTool = await this.contextExternalToolService.findById(original.contextExternalToolId); + if (!linkedTool) { + const copyStatus: CopyStatus = { + copyEntity: copy, + type: CopyElementType.EXTERNAL_TOOL_ELEMENT, + status: CopyStatusEnum.FAIL, + }; - status = CopyStatusEnum.SUCCESS; - } else { - status = CopyStatusEnum.FAIL; - } + return copyStatus; + } + + const contextToolCopyResult: ContextExternalTool | CopyContextExternalToolRejectData = + await this.contextExternalToolService.copyContextExternalTool(linkedTool, copy.id, context.targetSchoolId); + + let copyStatus: CopyStatusEnum = CopyStatusEnum.SUCCESS; + if (contextToolCopyResult instanceof CopyContextExternalToolRejectData) { + copy = new DeletedElement({ + id: new ObjectId().toHexString(), + path: copy.path, + level: copy.level, + position: copy.position, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + deletedElementType: ContentElementType.EXTERNAL_TOOL, + title: contextToolCopyResult.externalToolName, + }); + + copyStatus = CopyStatusEnum.FAIL; } else { - status = CopyStatusEnum.SUCCESS; + copy.contextExternalToolId = contextToolCopyResult.id; } const result: CopyStatus = { copyEntity: copy, type: CopyElementType.EXTERNAL_TOOL_ELEMENT, - status, + status: copyStatus, }; return Promise.resolve(result); diff --git a/apps/server/src/modules/board/service/internal/column-board-copy.service.spec.ts b/apps/server/src/modules/board/service/internal/column-board-copy.service.spec.ts index e78022f73e..25feffbeb2 100644 --- a/apps/server/src/modules/board/service/internal/column-board-copy.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/column-board-copy.service.spec.ts @@ -63,6 +63,7 @@ describe(ColumnBoardCopyService.name, () => { describe('copyColumnBoard', () => { const setup = () => { const userId = new ObjectId().toHexString(); + const targetSchoolId = new ObjectId().toHexString(); const course = courseFactory.buildWithId(); const originalBoard = columnBoardFactory.build({ context: { id: course.id, type: BoardExternalReferenceType.Course }, @@ -86,6 +87,7 @@ describe(ColumnBoardCopyService.name, () => { targetStorageLocationReference: { id: course.school.id, type: StorageLocation.SCHOOL }, userId, copyTitle: 'Board Copy', + targetSchoolId, }; return { originalBoard, userId, copyParams }; @@ -165,6 +167,7 @@ describe(ColumnBoardCopyService.name, () => { describe('when the copy response is not a ColumnBoard', () => { const setup = () => { const userId = new ObjectId().toHexString(); + const targetSchoolId = new ObjectId().toHexString(); const course = courseFactory.buildWithId(); const originalBoard = columnBoardFactory.build({ context: { id: course.id, type: BoardExternalReferenceType.Course }, @@ -178,6 +181,7 @@ describe(ColumnBoardCopyService.name, () => { targetStorageLocationReference: { id: course.school.id, type: StorageLocation.SCHOOL }, userId, copyTitle: 'Board Copy', + targetSchoolId, }; return { originalBoard, userId, copyParams }; diff --git a/apps/server/src/modules/board/service/internal/column-board-copy.service.ts b/apps/server/src/modules/board/service/internal/column-board-copy.service.ts index 531ec8c351..6c5b4a31b0 100644 --- a/apps/server/src/modules/board/service/internal/column-board-copy.service.ts +++ b/apps/server/src/modules/board/service/internal/column-board-copy.service.ts @@ -15,6 +15,7 @@ export type CopyColumnBoardParams = { targetStorageLocationReference: StorageLocationReference; userId: EntityId; copyTitle?: string; + targetSchoolId: EntityId; }; @Injectable() @@ -36,6 +37,7 @@ export class ColumnBoardCopyService { targetStorageLocationReference: params.targetStorageLocationReference, userId: params.userId, filesStorageClientAdapterService: this.filesStorageClientAdapterService, + targetSchoolId: params.targetSchoolId, }); const copyStatus = await this.boardNodeCopyService.copy(originalBoard, copyContext); diff --git a/apps/server/src/modules/board/uc/board.uc.spec.ts b/apps/server/src/modules/board/uc/board.uc.spec.ts index ff22353895..276f327545 100644 --- a/apps/server/src/modules/board/uc/board.uc.spec.ts +++ b/apps/server/src/modules/board/uc/board.uc.spec.ts @@ -442,7 +442,7 @@ describe(BoardUc.name, () => { it('should call the service to find the user', async () => { const { user, boardId } = setup(); - await uc.copyBoard(user.id, boardId); + await uc.copyBoard(user.id, boardId, user.school.id); expect(authorizationService.getUserWithPermissions).toHaveBeenCalledWith(user.id); }); @@ -450,7 +450,7 @@ describe(BoardUc.name, () => { it('should call the service to find the board', async () => { const { user, boardId } = setup(); - await uc.copyBoard(user.id, boardId); + await uc.copyBoard(user.id, boardId, user.school.id); expect(boardNodeService.findByClassAndId).toHaveBeenCalledWith(ColumnBoard, boardId); }); @@ -458,7 +458,7 @@ describe(BoardUc.name, () => { it('[deprecated] should call course repo to find the course', async () => { const { user, boardId } = setup(); - await uc.copyBoard(user.id, boardId); + await uc.copyBoard(user.id, boardId, user.school.id); expect(courseRepo.findById).toHaveBeenCalled(); }); @@ -466,7 +466,7 @@ describe(BoardUc.name, () => { it('should call Board Permission Service to check permission', async () => { const { user, board } = setup(); - await uc.copyBoard(user.id, board.id); + await uc.copyBoard(user.id, board.id, user.school.id); expect(boardPermissionService.checkPermission).toHaveBeenCalledWith(user.id, board, Action.read); }); @@ -478,7 +478,7 @@ describe(BoardUc.name, () => { // TODO should not use course repo courseRepo.findById.mockResolvedValueOnce(course); - await uc.copyBoard(user.id, boardId); + await uc.copyBoard(user.id, boardId, user.school.id); expect(authorizationService.checkPermission).toHaveBeenCalledWith(user, course, { action: Action.write, @@ -489,7 +489,7 @@ describe(BoardUc.name, () => { it('should call the service to copy the board', async () => { const { user, boardId } = setup(); - await uc.copyBoard(user.id, boardId); + await uc.copyBoard(user.id, boardId, user.school.id); expect(columnBoardService.copyColumnBoard).toHaveBeenCalledWith( expect.objectContaining({ userId: user.id, originalColumnBoardId: boardId }) @@ -505,7 +505,7 @@ describe(BoardUc.name, () => { }; columnBoardService.copyColumnBoard.mockResolvedValueOnce(copyStatus); - const result = await uc.copyBoard(user.id, boardId); + const result = await uc.copyBoard(user.id, boardId, user.school.id); expect(result).toEqual(copyStatus); }); diff --git a/apps/server/src/modules/board/uc/board.uc.ts b/apps/server/src/modules/board/uc/board.uc.ts index 3dae0f22bb..83d250541c 100644 --- a/apps/server/src/modules/board/uc/board.uc.ts +++ b/apps/server/src/modules/board/uc/board.uc.ts @@ -116,7 +116,7 @@ export class BoardUc { return column; } - async copyBoard(userId: EntityId, boardId: EntityId): Promise { + async copyBoard(userId: EntityId, boardId: EntityId, schoolId: EntityId): Promise { this.logger.debug({ action: 'copyBoard', userId, boardId }); const board = await this.boardNodeService.findByClassAndId(ColumnBoard, boardId); @@ -132,6 +132,7 @@ export class BoardUc { sourceStorageLocationReference: storageLocationReference, targetStorageLocationReference: storageLocationReference, userId, + targetSchoolId: schoolId, }); return copyStatus; diff --git a/apps/server/src/modules/learnroom/service/board-copy.service.spec.ts b/apps/server/src/modules/learnroom/service/board-copy.service.spec.ts index 0f82cd9f79..376f89eec5 100644 --- a/apps/server/src/modules/learnroom/service/board-copy.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/board-copy.service.spec.ts @@ -324,6 +324,7 @@ describe('board copy service', () => { sourceStorageLocationReference: { id: destinationCourse.school.id, type: StorageLocation.SCHOOL }, targetStorageLocationReference: { id: destinationCourse.school.id, type: StorageLocation.SCHOOL }, userId: user.id, + targetSchoolId: user.school.id, }; await copyService.copyBoard({ originalBoard, user, originalCourse: destinationCourse, destinationCourse }); diff --git a/apps/server/src/modules/learnroom/service/board-copy.service.ts b/apps/server/src/modules/learnroom/service/board-copy.service.ts index 2678f13d3e..c4e465cde9 100644 --- a/apps/server/src/modules/learnroom/service/board-copy.service.ts +++ b/apps/server/src/modules/learnroom/service/board-copy.service.ts @@ -153,6 +153,7 @@ export class BoardCopyService { sourceStorageLocationReference: { id: originalCourse.school.id, type: StorageLocation.SCHOOL }, targetStorageLocationReference: { id: destinationCourse.school.id, type: StorageLocation.SCHOOL }, userId: user.id, + targetSchoolId: user.school.id, }); } diff --git a/apps/server/src/modules/learnroom/service/course-copy.service.spec.ts b/apps/server/src/modules/learnroom/service/course-copy.service.spec.ts index fa89376fba..41f9cf8e44 100644 --- a/apps/server/src/modules/learnroom/service/course-copy.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/course-copy.service.spec.ts @@ -1,9 +1,11 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { CopyElementType, CopyHelperService, CopyStatusEnum } from '@modules/copy-helper'; +import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; import { LessonCopyService } from '@modules/lesson/service'; import { ToolContextType } from '@modules/tool/common/enum'; -import { ContextExternalTool } from '@modules/tool/context-external-tool/domain'; +import { SchoolExternalTool } from '@modules/tool/school-external-tool/domain'; +import { ContextExternalTool, CopyContextExternalToolRejectData } from '@modules/tool/context-external-tool/domain'; import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; +import { schoolExternalToolFactory } from '@modules/tool/school-external-tool/testing'; import { ToolConfig } from '@modules/tool/tool-config'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; @@ -17,7 +19,10 @@ import { setupEntities, userFactory, } from '@shared/testing'; -import { contextExternalToolFactory } from '../../tool/context-external-tool/testing'; +import { + contextExternalToolFactory, + copyContextExternalToolRejectDataFactory, +} from '../../tool/context-external-tool/testing'; import { BoardCopyService } from './board-copy.service'; import { CourseCopyService } from './course-copy.service'; import { CourseRoomsService } from './course-rooms.service'; @@ -105,13 +110,20 @@ describe('course copy service', () => { describe('copy course', () => { const setup = () => { - const user = userFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); + const user = userFactory.buildWithId({ school }); const allCourses = courseFactory.buildList(3, { teachers: [user] }); const course = allCourses[0]; const originalBoard = boardFactory.build({ course }); const courseCopy = courseFactory.buildWithId({ teachers: [user] }); const boardCopy = boardFactory.build({ course: courseCopy }); - const tools: ContextExternalTool[] = contextExternalToolFactory.buildList(2); + const schoolTool: SchoolExternalTool = schoolExternalToolFactory.build({ schoolId: school.id }); + const tools: ContextExternalTool[] = contextExternalToolFactory.buildList(2, { + schoolToolRef: { + schoolToolId: schoolTool.id, + schoolId: school.id, + }, + }); userRepo.findById.mockResolvedValue(user); courseRepo.findById.mockResolvedValue(course); @@ -349,8 +361,112 @@ describe('course copy service', () => { const status = await service.copyCourse({ userId: user.id, courseId: course.id }); const courseCopy = status.copyEntity as Course; - expect(contextExternalToolService.copyContextExternalTool).toHaveBeenCalledWith(tools[0], courseCopy.id); - expect(contextExternalToolService.copyContextExternalTool).toHaveBeenCalledWith(tools[1], courseCopy.id); + expect(contextExternalToolService.copyContextExternalTool).toHaveBeenCalledWith( + tools[0], + courseCopy.id, + tools[0].schoolToolRef.schoolId + ); + expect(contextExternalToolService.copyContextExternalTool).toHaveBeenCalledWith( + tools[1], + courseCopy.id, + tools[0].schoolToolRef.schoolId + ); + }); + + describe('when the all ctl tools of course are successfully copied', () => { + it('should return in the elements field the copy status for course tools as success', async () => { + const { course, user } = setup(); + const status = await service.copyCourse({ userId: user.id, courseId: course.id }); + const courseToolCopyStatus: CopyStatus | undefined = status.elements?.find( + (copyStatus: CopyStatus) => copyStatus.type === CopyElementType.EXTERNAL_TOOL + ); + + expect(courseToolCopyStatus).not.toBeUndefined(); + expect(courseToolCopyStatus?.status).toEqual(CopyStatusEnum.SUCCESS); + }); + }); + + describe('when only some of the ctl tools of course are successfully copied', () => { + const setupPartialCopySuccessTools = () => { + const { course, user, tools } = setup(); + + const copyRejectData = copyContextExternalToolRejectDataFactory.build(); + const mockWithCorrectType = Object.create( + CopyContextExternalToolRejectData.prototype + ) as CopyContextExternalToolRejectData; + Object.assign(mockWithCorrectType, { ...copyRejectData }); + + contextExternalToolService.copyContextExternalTool.mockResolvedValueOnce(mockWithCorrectType); + contextExternalToolService.copyContextExternalTool.mockResolvedValueOnce(tools[0]); + + copyHelperService.deriveStatusFromElements.mockReturnValue(CopyStatusEnum.PARTIAL); + + return { course, user }; + }; + + it('should return in the elements field the copy status for course tools as partial', async () => { + const { course, user } = setupPartialCopySuccessTools(); + const status = await service.copyCourse({ userId: user.id, courseId: course.id }); + const courseToolCopyStatus: CopyStatus | undefined = status.elements?.find( + (copyStatus: CopyStatus) => copyStatus.type === CopyElementType.EXTERNAL_TOOL + ); + + expect(courseToolCopyStatus).not.toBeUndefined(); + expect(courseToolCopyStatus?.status).toEqual(CopyStatusEnum.PARTIAL); + }); + }); + + describe('when the all ctl tools of course failed to be copied', () => { + const setupAllCopyFailedTools = () => { + const { course, user } = setup(); + + const copyRejectData = copyContextExternalToolRejectDataFactory.build(); + const mockWithCorrectType = Object.create( + CopyContextExternalToolRejectData.prototype + ) as CopyContextExternalToolRejectData; + Object.assign(mockWithCorrectType, { ...copyRejectData }); + + contextExternalToolService.copyContextExternalTool.mockResolvedValueOnce(mockWithCorrectType); + contextExternalToolService.copyContextExternalTool.mockResolvedValueOnce(mockWithCorrectType); + + copyHelperService.deriveStatusFromElements.mockReturnValue(CopyStatusEnum.FAIL); + + return { course, user }; + }; + + it('should return in the elements field the copy status for course tools as partial', async () => { + const { course, user } = setupAllCopyFailedTools(); + const status = await service.copyCourse({ userId: user.id, courseId: course.id }); + const courseToolCopyStatus: CopyStatus | undefined = status.elements?.find( + (copyStatus: CopyStatus) => copyStatus.type === CopyElementType.EXTERNAL_TOOL + ); + + expect(courseToolCopyStatus).not.toBeUndefined(); + expect(courseToolCopyStatus?.status).toEqual(CopyStatusEnum.FAIL); + }); + }); + + describe('when there are no ctl tools to copy', () => { + const setupNoTools = () => { + const { course, user } = setup(); + + contextExternalToolService.findAllByContext.mockResolvedValueOnce([]); + + copyHelperService.deriveStatusFromElements.mockReturnValue(CopyStatusEnum.SUCCESS); + + return { course, user }; + }; + + it('should not return copy status of course tools in the elements field', async () => { + const { course, user } = setupNoTools(); + + const status = await service.copyCourse({ userId: user.id, courseId: course.id }); + const courseToolCopyStatus: CopyStatus | undefined = status.elements?.find( + (copyStatus: CopyStatus) => copyStatus.type === CopyElementType.EXTERNAL_TOOL + ); + + expect(courseToolCopyStatus).toBeUndefined(); + }); }); }); @@ -403,6 +519,17 @@ describe('course copy service', () => { expect(contextExternalToolService.copyContextExternalTool).not.toHaveBeenCalled(); }); + + it('should not return copy status of course tools in the elements field', async () => { + const { course, user } = setup(); + + const status = await service.copyCourse({ userId: user.id, courseId: course.id }); + const courseToolCopyStatus: CopyStatus | undefined = status.elements?.find( + (copyStatus: CopyStatus) => copyStatus.type === CopyElementType.EXTERNAL_TOOL + ); + + expect(courseToolCopyStatus).toBeUndefined(); + }); }); describe('when course is empty', () => { diff --git a/apps/server/src/modules/learnroom/service/course-copy.service.ts b/apps/server/src/modules/learnroom/service/course-copy.service.ts index 66da0757f2..d3b82021d0 100644 --- a/apps/server/src/modules/learnroom/service/course-copy.service.ts +++ b/apps/server/src/modules/learnroom/service/course-copy.service.ts @@ -1,6 +1,10 @@ import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; import { ToolContextType } from '@modules/tool/common/enum'; -import { ContextExternalTool, ContextRef } from '@modules/tool/context-external-tool/domain'; +import { + ContextExternalTool, + ContextRef, + CopyContextExternalToolRejectData, +} from '@modules/tool/context-external-tool/domain'; import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; import { ToolConfig } from '@modules/tool/tool-config'; import { Injectable } from '@nestjs/common'; @@ -53,21 +57,25 @@ export class CourseCopyService { // copy course and board const courseCopy = await this.copyCourseEntity({ user, originalCourse, copyName }); + + let courseToolsCopyStatus: CopyStatus | null = null; if (this.configService.get('FEATURE_CTL_TOOLS_COPY_ENABLED')) { const contextRef: ContextRef = { id: courseId, type: ToolContextType.COURSE }; const contextExternalToolsInContext: ContextExternalTool[] = await this.contextExternalToolService.findAllByContext(contextRef); - await Promise.all( - contextExternalToolsInContext.map(async (tool: ContextExternalTool): Promise => { - const copiedTool: ContextExternalTool = await this.contextExternalToolService.copyContextExternalTool( - tool, - courseCopy.id - ); + const copyCourseToolsResult = await Promise.all( + contextExternalToolsInContext.map( + async (tool: ContextExternalTool): Promise => { + const copiedResult: ContextExternalTool | CopyContextExternalToolRejectData = + await this.contextExternalToolService.copyContextExternalTool(tool, courseCopy.id, user.school.id); - return copiedTool; - }) + return copiedResult; + } + ) ); + + courseToolsCopyStatus = this.deriveCourseToolCopyStatus(copyCourseToolsResult); } const boardStatus = await this.boardCopyService.copyBoard({ @@ -78,7 +86,12 @@ export class CourseCopyService { }); const finishedCourseCopy = await this.finishCourseCopying(courseCopy); - const courseStatus = this.deriveCourseStatus(originalCourse, finishedCourseCopy, boardStatus); + const courseStatus = this.deriveCourseStatus( + originalCourse, + finishedCourseCopy, + boardStatus, + courseToolsCopyStatus + ); return courseStatus; } @@ -105,7 +118,12 @@ export class CourseCopyService { return courseCopy; } - private deriveCourseStatus(originalCourse: Course, courseCopy: Course, boardStatus: CopyStatus): CopyStatus { + private deriveCourseStatus( + originalCourse: Course, + courseCopy: Course, + boardStatus: CopyStatus, + courseToolsCopyStatus: CopyStatus | null + ): CopyStatus { const elements = [ { type: CopyElementType.METADATA, @@ -126,11 +144,8 @@ export class CourseCopyService { boardStatus, ]; - if (this.configService.get('FEATURE_CTL_TOOLS_COPY_ENABLED')) { - elements.push({ - type: CopyElementType.EXTERNAL_TOOL, - status: CopyStatusEnum.SUCCESS, - }); + if (courseToolsCopyStatus) { + elements.push(courseToolsCopyStatus); } const courseGroupsExist = originalCourse.getCourseGroupItems().length > 0; @@ -151,4 +166,30 @@ export class CourseCopyService { }; return status; } + + private deriveCourseToolCopyStatus( + copyCourseToolsResult: (ContextExternalTool | CopyContextExternalToolRejectData)[] + ): CopyStatus | null { + if (!copyCourseToolsResult.length) { + return null; + } + + const rejectedCopies: CopyContextExternalToolRejectData[] = copyCourseToolsResult.filter( + (result) => result instanceof CopyContextExternalToolRejectData + ); + + let status: CopyStatusEnum; + if (rejectedCopies.length === copyCourseToolsResult.length) { + status = CopyStatusEnum.FAIL; + } else if (rejectedCopies.length > 0) { + status = CopyStatusEnum.PARTIAL; + } else { + status = CopyStatusEnum.SUCCESS; + } + + return { + type: CopyElementType.EXTERNAL_TOOL, + status, + }; + } } diff --git a/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts b/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts index ff24d2ed78..02309bc324 100644 --- a/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts +++ b/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts @@ -1,30 +1,47 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { EntityManager } from '@mikro-orm/mongodb'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { CopyApiResponse, CopyElementType, CopyStatusEnum } from '@modules/copy-helper'; -import { ServerTestModule } from '@modules/server'; +import { serverConfig, type ServerConfig, ServerTestModule } from '@modules/server'; import { HttpStatus, INestApplication } from '@nestjs/common'; -import { Test } from '@nestjs/testing'; +import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; +import { Course } from '@shared/domain/entity'; +import { BoardExternalReferenceType } from '@modules/board'; +import { BoardNodeType } from '@modules/board/domain'; +import { BoardNodeEntity } from '@modules/board/repo'; +import { + cardEntityFactory, + columnBoardEntityFactory, + columnEntityFactory, + externalToolElementEntityFactory, +} from '@modules/board/testing'; +import { ContextExternalToolEntity, ContextExternalToolType } from '@modules/tool/context-external-tool/entity'; +import { externalToolEntityFactory } from '@modules/tool/external-tool/testing'; +import { schoolExternalToolEntityFactory } from '@modules/tool/school-external-tool/testing'; +import { contextExternalToolEntityFactory } from '@modules/tool/context-external-tool/testing'; import { cleanupCollections, courseFactory, - roleFactory, schoolEntityFactory, TestApiClient, UserAndAccountTestFactory, - userFactory, } from '@shared/testing'; -import { ShareTokenContext, ShareTokenContextType, ShareTokenParentType } from '../../domainobject/share-token.do'; -import { ShareTokenService } from '../../service'; +import { shareTokenFactory } from '../../testing/share-token.factory'; +import { ShareTokenContextType } from '../../domainobject/share-token.do'; +import { ShareTokenImportBodyParams } from '../dto'; describe(`Share Token Import (API)`, () => { + const getSubPath = (token: string): string => { + const subPath = `/${token}/import`; + return subPath; + }; + let app: INestApplication; let em: EntityManager; let testApiClient: TestApiClient; - let shareTokenService: ShareTokenService; beforeAll(async () => { - const module = await Test.createTestingModule({ + const module: TestingModule = await Test.createTestingModule({ imports: [ServerTestModule], }).compile(); @@ -32,18 +49,52 @@ describe(`Share Token Import (API)`, () => { await app.init(); em = module.get(EntityManager); testApiClient = new TestApiClient(app, 'sharetoken'); - shareTokenService = module.get(ShareTokenService); + }); + + afterAll(async () => { + await app.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); }); beforeEach(async () => { await cleanupCollections(em); + Configuration.set('FEATURE_COURSE_SHARE', true); - }); + Configuration.set('FEATURE_COLUMN_BOARD_SHARE', true); - afterAll(async () => { - await app.close(); + const config: ServerConfig = serverConfig(); + config.FEATURE_CTL_TOOLS_COPY_ENABLED = true; }); + const setupSchoolExclusiveImport = async () => { + await cleanupCollections(em); + + const school = schoolEntityFactory.buildWithId(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const course = courseFactory.buildWithId({ teachers: [teacherUser], school: teacherUser.school }); + + const shareToken = shareTokenFactory.withParentTypeCourse().build({ + parentId: course.id, + contextType: ShareTokenContextType.School, + contextId: school.id, + }); + + await em.persistAndFlush([teacherAccount, teacherUser, school, course, shareToken]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + token: shareToken.token, + elementType: CopyElementType.COURSE, + course, + }; + }; + describe('POST /sharetoken/:token/import', () => { describe('when the user is not authenticated', () => { it('should return a 401 error', async () => { @@ -55,118 +106,719 @@ describe(`Share Token Import (API)`, () => { }); }); - describe('when the user is valid', () => { - const setup = async (context?: ShareTokenContext) => { - const school = schoolEntityFactory.build(); - const roles = roleFactory.buildList(1, { - permissions: [Permission.COURSE_CREATE], + describe('with the feature disabled', () => { + beforeEach(() => { + Configuration.set('FEATURE_COURSE_SHARE', false); + }); + + it('should return a 403 error', async () => { + const { loggedInClient, token } = await setupSchoolExclusiveImport(); + const response = await loggedInClient.post(`${token}/import`, { + newName: 'NewName', }); - const user = userFactory.build({ school, roles }); - const course = courseFactory.build({ teachers: [user] }); - await em.persistAndFlush([user, course]); - - const shareToken = await shareTokenService.createToken( - { - parentType: ShareTokenParentType.Course, - parentId: course.id, - }, - { context } - ); + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + }); - const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); - await em.persistAndFlush([teacherAccount, teacherUser]); - em.clear(); + describe('with a valid token', () => { + it('should return status 201', async () => { + const { loggedInClient, token } = await setupSchoolExclusiveImport(); - const loggedInClient = await testApiClient.login(teacherAccount); + const response = await loggedInClient.post(getSubPath(token), { newName: 'NewName' }); - return { - loggedInClient, - token: shareToken.token, - elementType: CopyElementType.COURSE, + expect(response.status).toEqual(HttpStatus.CREATED); + }); + + it('should return a valid result', async () => { + const { loggedInClient, token, elementType } = await setupSchoolExclusiveImport(); + const newName = 'NewName'; + const response = await loggedInClient.post(getSubPath(token), { newName }); + + const expectedResult: CopyApiResponse = { + id: expect.any(String), + type: elementType, + title: newName, + status: CopyStatusEnum.SUCCESS, }; + + expect(response.body as CopyApiResponse).toEqual(expect.objectContaining(expectedResult)); + }); + }); + + describe('when doing a valid course import from another school', () => { + const setupCrossSchoolImport = async () => { + const targetSchool = schoolEntityFactory.buildWithId(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school: targetSchool }); + + const sourceSchool = schoolEntityFactory.buildWithId(); + const course = courseFactory.buildWithId({ school: sourceSchool }); + + const shareToken = shareTokenFactory.withParentTypeCourse().build({ + parentId: course.id, + contextType: undefined, + contextId: undefined, + }); + + await em.persistAndFlush([teacherAccount, teacherUser, targetSchool, course, shareToken]); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, token: shareToken.token, targetSchool, course }; }; - describe('with the feature disabled', () => { - beforeEach(() => { - Configuration.set('FEATURE_COURSE_SHARE', false); + describe('when the course has course tools', () => { + describe('when the importing school has the proper school external tool', () => { + const setupExistingSchoolTool = async () => { + const { loggedInClient, token, targetSchool, course } = await setupCrossSchoolImport(); + + const externalTool = externalToolEntityFactory.buildWithId(); + + const sourceSchoolTool = schoolExternalToolEntityFactory.buildWithId({ + school: course.school, + tool: externalTool, + }); + + const sourceCourseTools = contextExternalToolEntityFactory.buildList(2, { + schoolTool: sourceSchoolTool, + contextType: ContextExternalToolType.COURSE, + contextId: course.id, + }); + + const targetSchoolTool = schoolExternalToolEntityFactory.buildWithId({ + school: targetSchool, + tool: externalTool, + }); + + await em.persistAndFlush([externalTool, targetSchoolTool, sourceSchoolTool, ...sourceCourseTools]); + em.clear(); + + return { + loggedInClient, + token, + targetSchool, + targetSchoolTool, + }; + }; + + it('should save the copied course', async () => { + const { loggedInClient, token, targetSchool } = await setupExistingSchoolTool(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 'newName' }); + + expect(response.status).toEqual(201); + + await em.findOneOrFail(Course, { school: targetSchool }); + }); + + it('should save the course tools with the correct external school id', async () => { + const { loggedInClient, token, targetSchool, targetSchoolTool } = await setupExistingSchoolTool(); + + const newName = 'newName'; + const response = await loggedInClient.post(getSubPath(token), { newName }); + + expect(response.status).toEqual(201); + + const copiedCourse: Course = await em.findOneOrFail(Course, { school: targetSchool }); + const copiedCourseTools: ContextExternalToolEntity[] = await em.find(ContextExternalToolEntity, { + contextType: ContextExternalToolType.COURSE, + contextId: new ObjectId(copiedCourse.id), + }); + + expect(copiedCourseTools.length).toEqual(2); + copiedCourseTools.forEach((courseTool) => { + expect(courseTool.schoolTool.id).toEqual(targetSchoolTool.id); + }); + }); }); - it('should return a 403 error', async () => { - const { loggedInClient, token } = await setup(); - const response = await loggedInClient.post(`${token}/import`, { - newName: 'NewName', + describe('when the importing school does not have the proper school external tool', () => { + const setupNonExistingSchoolTool = async () => { + const { loggedInClient, token, targetSchool, course } = await setupCrossSchoolImport(); + + const sourceSchoolTool = schoolExternalToolEntityFactory.buildWithId({ + school: course.school, + }); + + const sourceCourseTools = contextExternalToolEntityFactory.buildListWithId(2, { + schoolTool: sourceSchoolTool, + contextType: ContextExternalToolType.COURSE, + contextId: course.id, + }); + + await em.persistAndFlush([sourceSchoolTool, ...sourceCourseTools]); + em.clear(); + + return { loggedInClient, token, targetSchool }; + }; + + it('should save the copied course', async () => { + const { loggedInClient, token, targetSchool } = await setupNonExistingSchoolTool(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 'newName' }); + + expect(response.status).toEqual(201); + + await em.findOneOrFail(Course, { school: targetSchool }); + }); + + it('should not save the course tools', async () => { + const { loggedInClient, token, targetSchool } = await setupNonExistingSchoolTool(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 'newName' }); + + expect(response.status).toEqual(201); + + const copiedCourse: Course = await em.findOneOrFail(Course, { school: targetSchool }); + const copiedCourseTools: ContextExternalToolEntity[] = await em.find(ContextExternalToolEntity, { + contextType: ContextExternalToolType.COURSE, + contextId: new ObjectId(copiedCourse.id), + }); + + expect(copiedCourseTools.length).toEqual(0); }); - expect(response.status).toBe(HttpStatus.FORBIDDEN); }); }); - describe('with a valid token', () => { - it('should return status 201', async () => { - const { loggedInClient, token } = await setup(); + describe('when the course has boards with tool elements', () => { + const setupBoardEntitiesWithTools = ( + course: Course, + boardToolOne: ContextExternalToolEntity, + boardToolTwo: ContextExternalToolEntity + ) => { + const columnBoardNode = columnBoardEntityFactory.build({ + context: { + type: BoardExternalReferenceType.Course, + id: course.id, + }, + }); + + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + + const cardNode = cardEntityFactory.withParent(columnNode).build(); + + const boardToolElementOne = externalToolElementEntityFactory.withParent(cardNode).build({ + position: 0, + contextExternalToolId: boardToolOne.id, + }); + + const boardToolElementTwo = externalToolElementEntityFactory.withParent(cardNode).build({ + position: 1, + contextExternalToolId: boardToolTwo.id, + }); + + em.persist([columnBoardNode, columnNode, cardNode, boardToolElementOne, boardToolElementTwo]); + }; + + describe('when the importing school has the proper school external tool', () => { + const setupExistingSchoolTool = async () => { + const { loggedInClient, token, targetSchool, course } = await setupCrossSchoolImport(); + + const externalTool = externalToolEntityFactory.buildWithId(); + + const sourceSchoolTool = schoolExternalToolEntityFactory.buildWithId({ + school: course.school, + tool: externalTool, + }); - const response = await loggedInClient.post(`${token}/import`, { - newName: 'NewName', + const sourceBoardToolOne = contextExternalToolEntityFactory.buildWithId({ + schoolTool: sourceSchoolTool, + contextType: ContextExternalToolType.BOARD_ELEMENT, + contextId: new ObjectId().toHexString(), + }); + + const sourceBoardToolTwo = contextExternalToolEntityFactory.buildWithId({ + schoolTool: sourceSchoolTool, + contextType: ContextExternalToolType.BOARD_ELEMENT, + contextId: new ObjectId().toHexString(), + }); + + const targetSchoolTool = schoolExternalToolEntityFactory.buildWithId({ + school: targetSchool, + tool: externalTool, + }); + + setupBoardEntitiesWithTools(course, sourceBoardToolOne, sourceBoardToolTwo); + + await em.persistAndFlush([ + externalTool, + targetSchoolTool, + sourceSchoolTool, + sourceBoardToolOne, + sourceBoardToolTwo, + ]); + em.clear(); + + return { loggedInClient, token, targetSchool, targetSchoolTool }; + }; + + it('should save the copied course', async () => { + const { loggedInClient, token, targetSchool } = await setupExistingSchoolTool(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 'newName' }); + + expect(response.status).toEqual(201); + + await em.findOneOrFail(Course, { school: targetSchool }); + }); + + it('should save the copied board', async () => { + const { loggedInClient, token, targetSchool } = await setupExistingSchoolTool(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 'newName' }); + + expect(response.status).toEqual(201); + + const copiedCourse = await em.findOneOrFail(Course, { school: targetSchool }); + const columnBoardNodes: BoardNodeEntity[] = await em.find(BoardNodeEntity, { + type: BoardNodeType.COLUMN_BOARD, + }); + const copiedColumnBoardNode: BoardNodeEntity | undefined = columnBoardNodes.find( + (node) => node.context?.id === copiedCourse.id + ); + + expect(copiedColumnBoardNode).not.toBeUndefined(); + }); + + it('should copy the board tool elements', async () => { + const { loggedInClient, token } = await setupExistingSchoolTool(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 'newName' }); + + expect(response.status).toEqual(201); + + const persistedBoardTools: BoardNodeEntity[] = await em.find(BoardNodeEntity, { + type: BoardNodeType.EXTERNAL_TOOL, + }); + + expect(persistedBoardTools.length).toBeGreaterThan(2); }); - expect(response.status).toEqual(HttpStatus.CREATED); + it('should copy the board context external tools with the correct school external tool', async () => { + const { loggedInClient, token, targetSchoolTool } = await setupExistingSchoolTool(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 'newName' }); + + expect(response.status).toEqual(201); + + const copiedBoardTools: ContextExternalToolEntity[] = await em.find(ContextExternalToolEntity, { + contextType: ContextExternalToolType.BOARD_ELEMENT, + schoolTool: targetSchoolTool, + }); + + expect(copiedBoardTools.length).toEqual(2); + }); }); - it('should return a valid result', async () => { - const { loggedInClient, token, elementType } = await setup(); - const newName = 'NewName'; - const response = await loggedInClient.post(`${token}/import`, { - newName, - }); - const expectedResult: CopyApiResponse = { - id: expect.any(String), - type: elementType, - title: newName, - status: CopyStatusEnum.SUCCESS, + describe('when the importing school does not have the proper school external tool', () => { + const setupNonExistingSchoolTool = async () => { + const { loggedInClient, token, targetSchool, course } = await setupCrossSchoolImport(); + + const sourceSchoolTool = schoolExternalToolEntityFactory.buildWithId({ + school: course.school, + }); + + const sourceBoardToolOne = contextExternalToolEntityFactory.buildWithId({ + schoolTool: sourceSchoolTool, + contextType: ContextExternalToolType.BOARD_ELEMENT, + contextId: new ObjectId().toHexString(), + }); + + const sourceBoardToolTwo = contextExternalToolEntityFactory.buildWithId({ + schoolTool: sourceSchoolTool, + contextType: ContextExternalToolType.BOARD_ELEMENT, + contextId: new ObjectId().toHexString(), + }); + + setupBoardEntitiesWithTools(course, sourceBoardToolOne, sourceBoardToolTwo); + + await em.persistAndFlush([sourceSchoolTool, sourceBoardToolOne, sourceBoardToolTwo]); + em.clear(); + + return { loggedInClient, token, targetSchool }; }; - expect(response.body as CopyApiResponse).toEqual(expect.objectContaining(expectedResult)); + it('should save the copied course', async () => { + const { loggedInClient, token, targetSchool } = await setupNonExistingSchoolTool(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 'newName' }); + + expect(response.status).toEqual(201); + + await em.findOneOrFail(Course, { school: targetSchool }); + }); + + it('should save the copied board', async () => { + const { loggedInClient, token, targetSchool } = await setupNonExistingSchoolTool(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 'newName' }); + + expect(response.status).toEqual(201); + + const copiedCourse = await em.findOneOrFail(Course, { school: targetSchool }); + const columnBoardNodes: BoardNodeEntity[] = await em.find(BoardNodeEntity, { + type: BoardNodeType.COLUMN_BOARD, + }); + const copiedColumnBoardNode: BoardNodeEntity | undefined = columnBoardNodes.find( + (node) => node.context?.id === copiedCourse.id + ); + + expect(copiedColumnBoardNode).not.toBeUndefined(); + }); + + it('should not copy the board tools and replace them with deleted elements', async () => { + const { loggedInClient, token } = await setupNonExistingSchoolTool(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 'newName' }); + + expect(response.status).toEqual(201); + + const persistedBoardTools: ContextExternalToolEntity[] = await em.find(ContextExternalToolEntity, { + contextType: ContextExternalToolType.BOARD_ELEMENT, + }); + const deletedElementNodes: BoardNodeEntity[] = await em.find(BoardNodeEntity, { + type: BoardNodeType.DELETED_ELEMENT, + }); + + expect(persistedBoardTools.length).not.toBeGreaterThan(2); + expect(deletedElementNodes.length).toEqual(2); + }); }); }); + }); - describe('with invalid token', () => { - it('should return status 404', async () => { - const { loggedInClient } = await setup(); + describe('when doing a valid board import from another school', () => { + const setupCrossSchoolImport = async () => { + const targetSchool = schoolEntityFactory.buildWithId(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school: targetSchool }, [ + Permission.COURSE_EDIT, + ]); - const response = await loggedInClient.post(`invalid_token/import`, { - newName: 'NewName', - }); + const targetCourse = courseFactory.buildWithId({ + school: targetSchool, + teachers: [teacherUser], + }); - expect(response.status).toEqual(HttpStatus.NOT_FOUND); + const sourceSchool = schoolEntityFactory.buildWithId(); + const sourceCourse = courseFactory.buildWithId({ school: sourceSchool }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { + id: sourceCourse.id, + type: BoardExternalReferenceType.Course, + }, }); + + const shareToken = shareTokenFactory.withParentTypeBoard().build({ + parentId: columnBoardNode.id, + contextType: undefined, + contextId: undefined, + }); + + await em.persistAndFlush([ + teacherAccount, + teacherUser, + targetSchool, + targetCourse, + sourceCourse, + shareToken, + columnBoardNode, + ]); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + token: shareToken.token, + elementType: CopyElementType.COLUMNBOARD, + targetSchool, + targetCourse, + sourceCourse, + columnBoardNode, + }; + }; + + it('should return status 201', async () => { + const { loggedInClient, token, targetCourse } = await setupCrossSchoolImport(); + + const data: ShareTokenImportBodyParams = { + newName: 'newName', + destinationId: targetCourse.id, + }; + const response = await loggedInClient.post(getSubPath(token), data); + + expect(response.status).toEqual(201); + }); + + it('should return a valid response body', async () => { + const { loggedInClient, token, elementType, targetCourse } = await setupCrossSchoolImport(); + + const data: ShareTokenImportBodyParams = { + newName: 'newName', + destinationId: targetCourse.id, + }; + const response = await loggedInClient.post(getSubPath(token), data); + const body = response.body as CopyApiResponse; + + const expectedResult: CopyApiResponse = { + id: expect.any(String), + type: elementType, + status: CopyStatusEnum.SUCCESS, + }; + + expect(body).toEqual(expect.objectContaining(expectedResult)); }); - describe('with invalid context', () => { - it('should return status 403', async () => { - const otherSchool = schoolEntityFactory.build(); - await em.persistAndFlush(otherSchool); + describe('when the board has tool elements', () => { + const populateColumnBoardWithTools = ( + columnBoardNode: BoardNodeEntity, + boardToolOne: ContextExternalToolEntity, + boardToolTwo: ContextExternalToolEntity + ) => { + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); - const { loggedInClient, token: tokenFromOtherSchool } = await setup({ - contextId: otherSchool.id, - contextType: ShareTokenContextType.School, + const cardNode = cardEntityFactory.withParent(columnNode).build(); + + const boardToolElementOne = externalToolElementEntityFactory.withParent(cardNode).build({ + position: 0, + contextExternalToolId: boardToolOne.id, }); - const response = await loggedInClient.post(`${tokenFromOtherSchool}/import`, { - newName: 'NewName', + const boardToolElementTwo = externalToolElementEntityFactory.withParent(cardNode).build({ + position: 1, + contextExternalToolId: boardToolTwo.id, }); - expect(response.status).toEqual(HttpStatus.FORBIDDEN); + em.persist([columnBoardNode, columnNode, cardNode, boardToolElementOne, boardToolElementTwo]); + }; + + describe('when the importing school has the proper school external tool', () => { + const setupExistingSchoolTool = async () => { + const { loggedInClient, token, targetSchool, targetCourse, sourceCourse, columnBoardNode } = + await setupCrossSchoolImport(); + + const externalTool = externalToolEntityFactory.buildWithId(); + + const sourceSchoolTool = schoolExternalToolEntityFactory.buildWithId({ + school: sourceCourse.school, + tool: externalTool, + }); + + const sourceBoardToolOne = contextExternalToolEntityFactory.buildWithId({ + schoolTool: sourceSchoolTool, + contextType: ContextExternalToolType.BOARD_ELEMENT, + contextId: new ObjectId().toHexString(), + }); + + const sourceBoardToolTwo = contextExternalToolEntityFactory.buildWithId({ + schoolTool: sourceSchoolTool, + contextType: ContextExternalToolType.BOARD_ELEMENT, + contextId: new ObjectId().toHexString(), + }); + + const targetSchoolTool = schoolExternalToolEntityFactory.buildWithId({ + school: targetSchool, + tool: externalTool, + }); + + populateColumnBoardWithTools(columnBoardNode, sourceBoardToolOne, sourceBoardToolTwo); + + await em.persistAndFlush([ + externalTool, + targetSchoolTool, + sourceSchoolTool, + sourceBoardToolOne, + sourceBoardToolTwo, + ]); + em.clear(); + + return { loggedInClient, token, targetSchool, targetSchoolTool, targetCourse }; + }; + + it('should save the copied board', async () => { + const { loggedInClient, token, targetCourse } = await setupExistingSchoolTool(); + + const data: ShareTokenImportBodyParams = { + newName: 'newName', + destinationId: targetCourse.id, + }; + const response = await loggedInClient.post(getSubPath(token), data); + + expect(response.status).toEqual(201); + + const columnBoardNodes: BoardNodeEntity[] = await em.find(BoardNodeEntity, { + type: BoardNodeType.COLUMN_BOARD, + }); + const copiedColumnBoardNode: BoardNodeEntity | undefined = columnBoardNodes.find( + (node) => node.context?.id === targetCourse.id + ); + + expect(copiedColumnBoardNode).not.toBeUndefined(); + }); + + it('should copy the course tools and reassign them to the correct school external tool', async () => { + const { loggedInClient, token, targetSchoolTool, targetCourse } = await setupExistingSchoolTool(); + + const data: ShareTokenImportBodyParams = { + newName: 'newName', + destinationId: targetCourse.id, + }; + const response = await loggedInClient.post(getSubPath(token), data); + + expect(response.status).toEqual(201); + + const copiedBoardTools: ContextExternalToolEntity[] = await em.find(ContextExternalToolEntity, { + contextType: ContextExternalToolType.BOARD_ELEMENT, + schoolTool: targetSchoolTool, + }); + expect(copiedBoardTools.length).toEqual(2); + }); }); - }); - describe('with invalid new name', () => { - it('should return status 501', async () => { - const { loggedInClient, token } = await setup(); - const response = await loggedInClient.post(`${token}/import`, { - newName: 42, + describe('when the importing school does not have the proper school external tool', () => { + const setupNonExistingSchoolTool = async () => { + const { loggedInClient, token, targetSchool, targetCourse, sourceCourse, columnBoardNode } = + await setupCrossSchoolImport(); + + const sourceSchoolTool = schoolExternalToolEntityFactory.buildWithId({ + school: sourceCourse.school, + }); + + const sourceBoardToolOne = contextExternalToolEntityFactory.buildWithId({ + schoolTool: sourceSchoolTool, + contextType: ContextExternalToolType.BOARD_ELEMENT, + contextId: new ObjectId().toHexString(), + }); + + const sourceBoardToolTwo = contextExternalToolEntityFactory.buildWithId({ + schoolTool: sourceSchoolTool, + contextType: ContextExternalToolType.BOARD_ELEMENT, + contextId: new ObjectId().toHexString(), + }); + + populateColumnBoardWithTools(columnBoardNode, sourceBoardToolOne, sourceBoardToolTwo); + + await em.persistAndFlush([sourceSchoolTool, sourceBoardToolOne, sourceBoardToolTwo]); + em.clear(); + + return { loggedInClient, token, targetSchool, targetCourse }; + }; + + it('should save the copied board', async () => { + const { loggedInClient, token, targetCourse } = await setupNonExistingSchoolTool(); + + const data: ShareTokenImportBodyParams = { + newName: 'newName', + destinationId: targetCourse.id, + }; + const response = await loggedInClient.post(getSubPath(token), data); + + expect(response.status).toEqual(201); + + const columnBoardNodes: BoardNodeEntity[] = await em.find(BoardNodeEntity, { + type: BoardNodeType.COLUMN_BOARD, + }); + const copiedColumnBoardNode: BoardNodeEntity | undefined = columnBoardNodes.find( + (node) => node.context?.id === targetCourse.id + ); + expect(copiedColumnBoardNode).not.toBeUndefined(); + }); + + it('should not copy the board tool elements and replace them with deleted elements', async () => { + const { loggedInClient, token, targetCourse } = await setupNonExistingSchoolTool(); + + const data: ShareTokenImportBodyParams = { + newName: 'newName', + destinationId: targetCourse.id, + }; + const response = await loggedInClient.post(getSubPath(token), data); + + expect(response.status).toEqual(201); + + const persistedBoardToolElements: BoardNodeEntity[] = await em.find(BoardNodeEntity, { + type: BoardNodeType.EXTERNAL_TOOL, + }); + const deletedElementNodes: BoardNodeEntity[] = await em.find(BoardNodeEntity, { + type: BoardNodeType.DELETED_ELEMENT, + }); + + expect(persistedBoardToolElements.length).not.toBeGreaterThan(2); + expect(deletedElementNodes.length).toEqual(2); + }); + + it('should not copy the board context external tools', async () => { + const { loggedInClient, token, targetCourse } = await setupNonExistingSchoolTool(); + + const data: ShareTokenImportBodyParams = { + newName: 'newName', + destinationId: targetCourse.id, + }; + const response = await loggedInClient.post(getSubPath(token), data); + + expect(response.status).toEqual(201); + + const persistedBoardContextTools: ContextExternalToolEntity[] = await em.find(ContextExternalToolEntity, { + contextType: ContextExternalToolType.BOARD_ELEMENT, + }); + expect(persistedBoardContextTools.length).not.toBeGreaterThan(2); }); - expect(response.status).toEqual(HttpStatus.NOT_IMPLEMENTED); }); }); }); + + describe('with invalid token', () => { + it('should return status 404', async () => { + const { loggedInClient } = await setupSchoolExclusiveImport(); + + const response = await loggedInClient.post(getSubPath('invalid_token'), { newName: 'NewName' }); + + expect(response.status).toEqual(HttpStatus.NOT_FOUND); + }); + }); + + describe('with invalid context', () => { + const setupInvalidTokenContext = async () => { + await cleanupCollections(em); + + const school = schoolEntityFactory.buildWithId(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const course = courseFactory.buildWithId({ teachers: [teacherUser], school: teacherUser.school }); + + const otherSchool = schoolEntityFactory.buildWithId(); + + const shareToken = shareTokenFactory.withParentTypeCourse().build({ + parentId: course.id, + contextType: ShareTokenContextType.School, + contextId: otherSchool.id, + }); + + await em.persistAndFlush([teacherUser, teacherAccount, school, course, otherSchool, shareToken]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + shareTokenFromOtherSchool: shareToken.token, + }; + }; + + it('should return status 403', async () => { + const { loggedInClient, shareTokenFromOtherSchool } = await setupInvalidTokenContext(); + + const response = await loggedInClient.post(getSubPath(shareTokenFromOtherSchool), { newName: 'NewName' }); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('with invalid new name', () => { + it('should return status 501', async () => { + const { loggedInClient, token } = await setupSchoolExclusiveImport(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 42 }); + + expect(response.status).toEqual(HttpStatus.NOT_IMPLEMENTED); + }); + }); }); }); diff --git a/apps/server/src/modules/sharing/testing/share-token.factory.ts b/apps/server/src/modules/sharing/testing/share-token.factory.ts new file mode 100644 index 0000000000..a9faea781f --- /dev/null +++ b/apps/server/src/modules/sharing/testing/share-token.factory.ts @@ -0,0 +1,51 @@ +import { ShareToken, ShareTokenProperties } from '@modules/sharing/entity/share-token.entity'; +import { ShareTokenContextType, ShareTokenParentType } from '@modules/sharing/domainobject/share-token.do'; +import { BaseFactory } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { nanoid } from 'nanoid'; +import { DeepPartial } from 'fishery'; + +class ShareTokenFactory extends BaseFactory { + withParentTypeCourse(): this { + const parentType = ShareTokenParentType.Course; + const parentId = new ObjectId().toHexString(); + const params: DeepPartial = { parentType, parentId }; + + return this.params(params); + } + + withParentTypeBoard(): this { + const parentType = ShareTokenParentType.ColumnBoard; + const parentId = new ObjectId().toHexString(); + const params: DeepPartial = { parentType, parentId }; + + return this.params(params); + } + + withParentTypeTask(): this { + const parentType = ShareTokenParentType.Task; + const parentId = new ObjectId().toHexString(); + const params: DeepPartial = { parentType, parentId }; + + return this.params(params); + } + + withParentTypeLesson(): this { + const parentType = ShareTokenParentType.Lesson; + const parentId = new ObjectId().toHexString(); + const params: DeepPartial = { parentType, parentId }; + + return this.params(params); + } +} + +export const shareTokenFactory = ShareTokenFactory.define(ShareToken, () => { + return { + token: nanoid(12), + parentType: ShareTokenParentType.Course, + parentId: new ObjectId().toHexString(), + contextType: ShareTokenContextType.School, + contextId: new ObjectId().toHexString(), + expiresAt: new Date(Date.now() + 5 * 3600 * 1000), + }; +}); diff --git a/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts b/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts index 193f460628..5f496c86bf 100644 --- a/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts +++ b/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts @@ -1139,6 +1139,7 @@ describe('ShareTokenUC', () => { targetStorageLocationReference: { type: StorageLocation.SCHOOL, id: course.school.id }, userId: user.id, copyTitle: newName, + targetSchoolId: user.school.id, }); }); it('should return the result', async () => { diff --git a/apps/server/src/modules/sharing/uc/share-token.uc.ts b/apps/server/src/modules/sharing/uc/share-token.uc.ts index af4a079f10..9cd1a9f5c7 100644 --- a/apps/server/src/modules/sharing/uc/share-token.uc.ts +++ b/apps/server/src/modules/sharing/uc/share-token.uc.ts @@ -214,6 +214,7 @@ export class ShareTokenUC { targetStorageLocationReference, userId: user.id, copyTitle, + targetSchoolId: user.school.id, }); return copyStatus; } diff --git a/apps/server/src/modules/tool/context-external-tool/domain/copy-context-external-tool-reject-data.ts b/apps/server/src/modules/tool/context-external-tool/domain/copy-context-external-tool-reject-data.ts new file mode 100644 index 0000000000..eb3a3fcfe5 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/copy-context-external-tool-reject-data.ts @@ -0,0 +1,12 @@ +import { EntityId } from '@shared/domain/types'; + +export class CopyContextExternalToolRejectData { + readonly sourceContextExternalToolId: EntityId; + + readonly externalToolName: string; + + constructor(sourceContextExternalToolId: EntityId, externalToolName: string) { + this.sourceContextExternalToolId = sourceContextExternalToolId; + this.externalToolName = externalToolName; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/domain/index.ts b/apps/server/src/modules/tool/context-external-tool/domain/index.ts index e0b8c35772..c61b83991b 100644 --- a/apps/server/src/modules/tool/context-external-tool/domain/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/domain/index.ts @@ -5,3 +5,4 @@ export * from './event'; export * from './error'; export { LtiDeepLink } from './lti-deep-link'; export { LtiDeepLinkToken, LtiDeepLinkTokenProps } from './lti-deep-link-token'; +export { CopyContextExternalToolRejectData } from './copy-context-external-tool-reject-data'; diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.spec.ts index ac626ba0c1..e799941da4 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.spec.ts @@ -6,6 +6,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; import { ContextExternalToolRepo } from '@shared/repo'; import { legacySchoolDoFactory } from '@shared/testing'; +import { School } from '@modules/school'; +import { schoolFactory } from '@modules/school/testing'; import { CustomParameter } from '../../common/domain'; import { ToolContextType } from '../../common/enum'; import { CommonToolDeleteService, CommonToolService } from '../../common/service'; @@ -13,12 +15,13 @@ import { ExternalToolService } from '../../external-tool'; import { ExternalTool } from '../../external-tool/domain'; import { customParameterFactory, externalToolFactory } from '../../external-tool/testing'; import { SchoolExternalToolService } from '../../school-external-tool'; -import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { SchoolExternalTool, SchoolExternalToolRef } from '../../school-external-tool/domain'; import { schoolExternalToolFactory } from '../../school-external-tool/testing'; import { ContextExternalTool, ContextExternalToolProps, ContextRef, + CopyContextExternalToolRejectData, RestrictedContextMismatchLoggableException, } from '../domain'; import { contextExternalToolFactory } from '../testing'; @@ -430,64 +433,201 @@ describe(ContextExternalToolService.name, () => { }; }; - it('should find schoolExternalTool', async () => { - const { contextExternalTool, contextCopyId } = setup(); + describe('when the tool to copy is from the same school', () => { + it('should find schoolExternalTool', async () => { + const { contextExternalTool, contextCopyId, schoolExternalTool } = setup(); - await service.copyContextExternalTool(contextExternalTool, contextCopyId); + await service.copyContextExternalTool(contextExternalTool, contextCopyId, schoolExternalTool.schoolId); - expect(schoolExternalToolService.findById).toHaveBeenCalledWith(contextExternalTool.schoolToolRef.schoolToolId); - }); + expect(schoolExternalToolService.findById).toHaveBeenCalledWith(contextExternalTool.schoolToolRef.schoolToolId); + }); - it('should find externalTool', async () => { - const { contextExternalTool, contextCopyId, schoolExternalTool } = setup(); + it('should find externalTool', async () => { + const { contextExternalTool, contextCopyId, schoolExternalTool } = setup(); - await service.copyContextExternalTool(contextExternalTool, contextCopyId); + await service.copyContextExternalTool(contextExternalTool, contextCopyId, schoolExternalTool.schoolId); - expect(externalToolService.findById).toHaveBeenCalledWith(schoolExternalTool.toolId); - }); + expect(externalToolService.findById).toHaveBeenCalledWith(schoolExternalTool.toolId); + }); - it('should remove values from protected parameters', async () => { - const { contextExternalTool, contextCopyId } = setup(); - - const copiedTool: ContextExternalTool = await service.copyContextExternalTool(contextExternalTool, contextCopyId); - - expect(copiedTool).toEqual( - expect.objectContaining({ - id: expect.any(String), - contextRef: { id: contextCopyId, type: ToolContextType.COURSE }, - displayName: contextExternalTool.displayName, - schoolToolRef: contextExternalTool.schoolToolRef, - parameters: [ - { - name: contextExternalTool.parameters[0].name, - value: undefined, - }, - { - name: contextExternalTool.parameters[1].name, - value: contextExternalTool.parameters[1].value, - }, - ], - }) - ); - }); + it('should remove values from protected parameters', async () => { + const { contextExternalTool, contextCopyId, schoolExternalTool } = setup(); - it('should not copy unused parameter', async () => { - const { contextExternalTool, contextCopyId, unusedParam } = setup(); + let copiedTool: ContextExternalTool | CopyContextExternalToolRejectData = await service.copyContextExternalTool( + contextExternalTool, + contextCopyId, + schoolExternalTool.schoolId + ); + + expect(copiedTool instanceof ContextExternalTool).toEqual(true); + copiedTool = copiedTool as ContextExternalTool; + + expect(copiedTool).toEqual( + expect.objectContaining({ + id: expect.any(String), + contextRef: { id: contextCopyId, type: ToolContextType.COURSE }, + displayName: contextExternalTool.displayName, + schoolToolRef: contextExternalTool.schoolToolRef, + parameters: [ + { + name: contextExternalTool.parameters[0].name, + value: undefined, + }, + { + name: contextExternalTool.parameters[1].name, + value: contextExternalTool.parameters[1].value, + }, + ], + }) + ); + }); - const copiedTool: ContextExternalTool = await service.copyContextExternalTool(contextExternalTool, contextCopyId); + it('should not copy unused parameter', async () => { + const { contextExternalTool, contextCopyId, unusedParam, schoolExternalTool } = setup(); + + let copiedTool: ContextExternalTool | CopyContextExternalToolRejectData = await service.copyContextExternalTool( + contextExternalTool, + contextCopyId, + schoolExternalTool.schoolId + ); - expect(copiedTool.parameters.length).toEqual(2); - expect(copiedTool.parameters).not.toContain(unusedParam); + expect(copiedTool instanceof ContextExternalTool).toEqual(true); + copiedTool = copiedTool as ContextExternalTool; + + expect(copiedTool.parameters.length).toEqual(2); + expect(copiedTool.parameters).not.toContain(unusedParam); + }); + + it('should save copied tool', async () => { + const { contextExternalTool, contextCopyId, schoolExternalTool } = setup(); + + await service.copyContextExternalTool(contextExternalTool, contextCopyId, schoolExternalTool.schoolId); + + expect(contextExternalToolRepo.save).toHaveBeenCalledWith( + new ContextExternalTool({ ...contextExternalTool.getProps(), id: expect.any(String) }) + ); + }); }); - it('should save copied tool', async () => { - const { contextExternalTool, contextCopyId } = setup(); + describe('when the tool to copy is from another school', () => { + describe('when the target school has the correct school external tool', () => { + const setupTools = () => { + const { contextCopyId, contextExternalTool, schoolExternalTool } = setup(); + const sourceSchoolTool = schoolExternalTool; + + const targetSchool: School = schoolFactory.build(); + const targetSchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: sourceSchoolTool.toolId, + schoolId: targetSchool.id, + }); + + schoolExternalToolService.findSchoolExternalTools.mockResolvedValue([targetSchoolExternalTool]); + + const expectedSchoolToolRef: SchoolExternalToolRef = { + schoolToolId: targetSchoolExternalTool.id, + schoolId: targetSchool.id, + }; + + return { + contextCopyId, + contextExternalTool, + targetSchool, + targetSchoolExternalTool, + expectedSchoolToolRef, + }; + }; + + it('should find the correct SchoolExternalTool for the target school', async () => { + const { contextExternalTool, contextCopyId, targetSchool, targetSchoolExternalTool } = setupTools(); - await service.copyContextExternalTool(contextExternalTool, contextCopyId); + await service.copyContextExternalTool(contextExternalTool, contextCopyId, targetSchool.id); + + expect(schoolExternalToolService.findSchoolExternalTools).toHaveBeenCalledWith({ + toolId: targetSchoolExternalTool.toolId, + schoolId: targetSchool.id, + }); + }); - expect(contextExternalToolRepo.save).toHaveBeenCalledWith( - new ContextExternalTool({ ...contextExternalTool.getProps(), id: expect.any(String) }) - ); + it('should return the copied tool as type ContextExternalTool', async () => { + const { contextExternalTool, contextCopyId, targetSchool } = setupTools(); + + const copiedTool: ContextExternalTool | CopyContextExternalToolRejectData = + await service.copyContextExternalTool(contextExternalTool, contextCopyId, targetSchool.id); + + expect(copiedTool instanceof ContextExternalTool).toEqual(true); + }); + + it('should assign the copied tool the correct school tool', async () => { + const { contextExternalTool, contextCopyId, targetSchool, expectedSchoolToolRef } = setupTools(); + + let copiedTool: ContextExternalTool | CopyContextExternalToolRejectData = + await service.copyContextExternalTool(contextExternalTool, contextCopyId, targetSchool.id); + + expect(copiedTool instanceof ContextExternalTool).toEqual(true); + copiedTool = copiedTool as ContextExternalTool; + + expect(copiedTool.schoolToolRef).toMatchObject(expectedSchoolToolRef); + }); + + it('should saved the copied tool with correct school tool', async () => { + const { contextExternalTool, contextCopyId, expectedSchoolToolRef, targetSchool } = setupTools(); + + await service.copyContextExternalTool(contextExternalTool, contextCopyId, targetSchool.id); + + expect(contextExternalToolRepo.save).toBeCalledWith( + new ContextExternalTool({ + ...contextExternalTool.getProps(), + schoolToolRef: expectedSchoolToolRef, + id: expect.any(String), + }) + ); + }); + }); + + describe('when the target school does no have the correct school external tool', () => { + const setupTools = () => { + const { contextCopyId, contextExternalTool, schoolExternalTool } = setup(); + + const targetSchool: School = schoolFactory.build(); + + schoolExternalToolService.findSchoolExternalTools.mockResolvedValue([]); + + return { + contextCopyId, + contextExternalTool, + targetSchool, + sourceSchoolTool: schoolExternalTool, + }; + }; + + it('should find the correct SchoolExternalTool for the target school', async () => { + const { contextExternalTool, contextCopyId, targetSchool, sourceSchoolTool } = setupTools(); + + await service.copyContextExternalTool(contextExternalTool, contextCopyId, targetSchool.id); + + expect(schoolExternalToolService.findSchoolExternalTools).toHaveBeenCalledWith({ + toolId: sourceSchoolTool.toolId, + schoolId: targetSchool.id, + }); + }); + + it('should return an object type CopyContextExternalToolRejectData', async () => { + const { contextExternalTool, contextCopyId, targetSchool } = setupTools(); + + const copiedTool: ContextExternalTool | CopyContextExternalToolRejectData = + await service.copyContextExternalTool(contextExternalTool, contextCopyId, targetSchool.id); + + expect(copiedTool instanceof CopyContextExternalToolRejectData).toEqual(true); + }); + + it('should not save the copied tool to the database', async () => { + const { contextExternalTool, contextCopyId, targetSchool } = setupTools(); + + await service.copyContextExternalTool(contextExternalTool, contextCopyId, targetSchool.id); + + expect(contextExternalToolRepo.save).not.toBeCalled(); + }); + }); }); }); }); diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.ts index 61d39f19eb..d4f203c4f9 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.ts @@ -12,6 +12,7 @@ import { ContextExternalTool, ContextExternalToolLaunchable, ContextRef, + CopyContextExternalToolRejectData, RestrictedContextMismatchLoggableException, } from '../domain'; import { ContextExternalToolQuery } from '../uc/dto/context-external-tool.types'; @@ -81,8 +82,9 @@ export class ContextExternalToolService { public async copyContextExternalTool( contextExternalTool: ContextExternalTool, - contextCopyId: EntityId - ): Promise { + contextCopyId: EntityId, + targetSchoolId: EntityId + ): Promise { const copy = new ContextExternalTool({ ...contextExternalTool.getProps(), id: new ObjectId().toHexString(), @@ -110,6 +112,23 @@ export class ContextExternalToolService { } }); + if (schoolExternalTool.schoolId !== targetSchoolId) { + const correctSchoolExternalTools: SchoolExternalTool[] = + await this.schoolExternalToolService.findSchoolExternalTools({ + toolId: schoolExternalTool.toolId, + schoolId: targetSchoolId, + }); + + if (correctSchoolExternalTools.length) { + copy.schoolToolRef.schoolToolId = correctSchoolExternalTools[0].id; + copy.schoolToolRef.schoolId = correctSchoolExternalTools[0].schoolId; + } else { + const copyRejectData = new CopyContextExternalToolRejectData(contextExternalTool.id, externalTool.name); + + return copyRejectData; + } + } + const copiedTool: ContextExternalTool = await this.contextExternalToolRepo.save(copy); return copiedTool; diff --git a/apps/server/src/modules/tool/context-external-tool/testing/copy-context-external-tool-reject-data.factory.ts b/apps/server/src/modules/tool/context-external-tool/testing/copy-context-external-tool-reject-data.factory.ts new file mode 100644 index 0000000000..d46430d454 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/testing/copy-context-external-tool-reject-data.factory.ts @@ -0,0 +1,10 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { Factory } from 'fishery'; +import { CopyContextExternalToolRejectData } from '../domain'; + +export const copyContextExternalToolRejectDataFactory = Factory.define(() => { + return { + sourceContextExternalToolId: new ObjectId().toHexString(), + externalToolName: 'Test Tool', + }; +}); diff --git a/apps/server/src/modules/tool/context-external-tool/testing/index.ts b/apps/server/src/modules/tool/context-external-tool/testing/index.ts index a4ee8fd319..ffa7c991a5 100644 --- a/apps/server/src/modules/tool/context-external-tool/testing/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/testing/index.ts @@ -6,3 +6,4 @@ export { ltiDeepLinkTokenFactory } from './lti-deep-link-token.factory'; export { ltiDeepLinkTokenEntityFactory } from './lti-deep-link-token-entity.factory'; export { Lti11DeepLinkParamsFactory } from './lti11-deep-link-params.factory'; export { ltiDeepLinkEmbeddableFactory } from './lti-deep-link-embeddable.factory'; +export { copyContextExternalToolRejectDataFactory } from './copy-context-external-tool-reject-data.factory'; diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index 1679b78b97..980709a221 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -331,5 +331,14 @@ "created_at": { "$date": "2024-11-28T18:05:40.839Z" } + }, + { + "_id": { + "$oid": "673fca34cc4a3264457c8ad1" + }, + "name": "Migration20241120100616", + "created_at": { + "$date": "2024-11-20T17:03:31.473Z" + } } ]