From 01065dca0619375fd460fb4fead22448f32c4330 Mon Sep 17 00:00:00 2001 From: WojciechGrancow <116577704+WojciechGrancow@users.noreply.github.com> Date: Tue, 7 Nov 2023 19:16:49 +0100 Subject: [PATCH] BC-5521 - Implement of KNL Control Module (#4486) * first commit * add some tests * add test cases and services * add usecases and test cases * fix importing * add type in uc * fix import * fix most of issue form review * add some test,additional status, limit ... * changing limit parameter and changing in useCases * Update apps/server/src/modules/deletion/uc/deletion-request.uc.ts Co-authored-by: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> * Update apps/server/src/modules/deletion/uc/deletion-request.uc.ts Co-authored-by: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> * Update apps/server/src/modules/deletion/services/deletion-request.service.ts Co-authored-by: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> * Update apps/server/src/modules/deletion/deletion.module.ts Co-authored-by: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> * fixes after review * small fixes --------- Co-authored-by: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> --- .../src/modules/deletion/deletion.module.ts | 11 + .../deletion/domain/deletion-log.do.spec.ts | 70 +++ .../deletion/domain/deletion-log.do.ts | 44 ++ .../domain/deletion-request.do.spec.ts | 69 +++ .../deletion/domain/deletion-request.do.ts | 39 ++ .../testing/factory/deletion-log.factory.ts | 18 + .../factory/deletion-request.factory.ts | 28 ++ .../types/deletion-domain-model.enum.ts | 11 + .../types/deletion-operation-model.enum.ts | 4 + .../types/deletion-status-model.enum.ts | 5 + .../entity/deletion-log.entity.spec.ts | 60 +++ .../deletion/entity/deletion-log.entity.ts | 67 +++ .../entity/deletion-request.entity.spec.ts | 85 ++++ .../entity/deletion-request.entity.ts | 60 +++ .../src/modules/deletion/entity/index.ts | 2 + .../factory/deletion-log.entity.factory.ts | 21 + .../deletion-request.entity.factory.ts | 20 + apps/server/src/modules/deletion/index.ts | 2 + .../deletion/repo/deletion-log.repo.spec.ts | 190 ++++++++ .../deletion/repo/deletion-log.repo.ts | 41 ++ .../deletion/repo/deletion-request-scope.ts | 17 + .../repo/deletion-request.repo.spec.ts | 342 +++++++++++++ .../deletion/repo/deletion-request.repo.ts | 86 ++++ .../server/src/modules/deletion/repo/index.ts | 2 + .../repo/mapper/deletion-log.mapper.spec.ts | 162 +++++++ .../repo/mapper/deletion-log.mapper.ts | 39 ++ .../mapper/deletion-request.mapper.spec.ts | 71 +++ .../repo/mapper/deletion-request.mapper.ts | 28 ++ .../src/modules/deletion/repo/mapper/index.ts | 2 + .../services/deletion-log.service.spec.ts | 110 +++++ .../deletion/services/deletion-log.service.ts | 37 ++ .../services/deletion-request.service.spec.ts | 200 ++++++++ .../services/deletion-request.service.ts | 61 +++ .../src/modules/deletion/services/index.ts | 1 + .../deletion-log-statistic.builder.spec.ts | 22 + .../builder/deletion-log-statistic.builder.ts | 10 + .../deletion-request-log.builder.spec.ts | 28 ++ .../builder/deletion-request-log.builder.ts | 13 + .../deletion-target-ref.builder.spec.ts | 20 + .../uc/builder/deletion-target-ref.builder.ts | 11 + .../deletion/uc/deletion-request.uc.spec.ts | 459 ++++++++++++++++++ .../deletion/uc/deletion-request.uc.ts | 209 ++++++++ .../modules/deletion/uc/interface/index.ts | 1 + .../deletion/uc/interface/interfaces.ts | 29 ++ .../src/modules/learnroom/service/index.ts | 1 + 45 files changed, 2808 insertions(+) create mode 100644 apps/server/src/modules/deletion/deletion.module.ts create mode 100644 apps/server/src/modules/deletion/domain/deletion-log.do.spec.ts create mode 100644 apps/server/src/modules/deletion/domain/deletion-log.do.ts create mode 100644 apps/server/src/modules/deletion/domain/deletion-request.do.spec.ts create mode 100644 apps/server/src/modules/deletion/domain/deletion-request.do.ts create mode 100644 apps/server/src/modules/deletion/domain/testing/factory/deletion-log.factory.ts create mode 100644 apps/server/src/modules/deletion/domain/testing/factory/deletion-request.factory.ts create mode 100644 apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts create mode 100644 apps/server/src/modules/deletion/domain/types/deletion-operation-model.enum.ts create mode 100644 apps/server/src/modules/deletion/domain/types/deletion-status-model.enum.ts create mode 100644 apps/server/src/modules/deletion/entity/deletion-log.entity.spec.ts create mode 100644 apps/server/src/modules/deletion/entity/deletion-log.entity.ts create mode 100644 apps/server/src/modules/deletion/entity/deletion-request.entity.spec.ts create mode 100644 apps/server/src/modules/deletion/entity/deletion-request.entity.ts create mode 100644 apps/server/src/modules/deletion/entity/index.ts create mode 100644 apps/server/src/modules/deletion/entity/testing/factory/deletion-log.entity.factory.ts create mode 100644 apps/server/src/modules/deletion/entity/testing/factory/deletion-request.entity.factory.ts create mode 100644 apps/server/src/modules/deletion/index.ts create mode 100644 apps/server/src/modules/deletion/repo/deletion-log.repo.spec.ts create mode 100644 apps/server/src/modules/deletion/repo/deletion-log.repo.ts create mode 100644 apps/server/src/modules/deletion/repo/deletion-request-scope.ts create mode 100644 apps/server/src/modules/deletion/repo/deletion-request.repo.spec.ts create mode 100644 apps/server/src/modules/deletion/repo/deletion-request.repo.ts create mode 100644 apps/server/src/modules/deletion/repo/index.ts create mode 100644 apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.spec.ts create mode 100644 apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.ts create mode 100644 apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.spec.ts create mode 100644 apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.ts create mode 100644 apps/server/src/modules/deletion/repo/mapper/index.ts create mode 100644 apps/server/src/modules/deletion/services/deletion-log.service.spec.ts create mode 100644 apps/server/src/modules/deletion/services/deletion-log.service.ts create mode 100644 apps/server/src/modules/deletion/services/deletion-request.service.spec.ts create mode 100644 apps/server/src/modules/deletion/services/deletion-request.service.ts create mode 100644 apps/server/src/modules/deletion/services/index.ts create mode 100644 apps/server/src/modules/deletion/uc/builder/deletion-log-statistic.builder.spec.ts create mode 100644 apps/server/src/modules/deletion/uc/builder/deletion-log-statistic.builder.ts create mode 100644 apps/server/src/modules/deletion/uc/builder/deletion-request-log.builder.spec.ts create mode 100644 apps/server/src/modules/deletion/uc/builder/deletion-request-log.builder.ts create mode 100644 apps/server/src/modules/deletion/uc/builder/deletion-target-ref.builder.spec.ts create mode 100644 apps/server/src/modules/deletion/uc/builder/deletion-target-ref.builder.ts create mode 100644 apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts create mode 100644 apps/server/src/modules/deletion/uc/deletion-request.uc.ts create mode 100644 apps/server/src/modules/deletion/uc/interface/index.ts create mode 100644 apps/server/src/modules/deletion/uc/interface/interfaces.ts diff --git a/apps/server/src/modules/deletion/deletion.module.ts b/apps/server/src/modules/deletion/deletion.module.ts new file mode 100644 index 00000000000..440a9418d70 --- /dev/null +++ b/apps/server/src/modules/deletion/deletion.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; +import { DeletionRequestService } from './services/deletion-request.service'; +import { DeletionRequestRepo } from './repo/deletion-request.repo'; + +@Module({ + imports: [LoggerModule], + providers: [DeletionRequestService, DeletionRequestRepo], + exports: [DeletionRequestService], +}) +export class DeletionModule {} diff --git a/apps/server/src/modules/deletion/domain/deletion-log.do.spec.ts b/apps/server/src/modules/deletion/domain/deletion-log.do.spec.ts new file mode 100644 index 00000000000..9117ded29c5 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/deletion-log.do.spec.ts @@ -0,0 +1,70 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { deletionLogFactory } from './testing/factory/deletion-log.factory'; +import { DeletionLog } from './deletion-log.do'; +import { DeletionOperationModel } from './types/deletion-operation-model.enum'; +import { DeletionDomainModel } from './types/deletion-domain-model.enum'; + +describe(DeletionLog.name, () => { + describe('constructor', () => { + describe('When constructor is called', () => { + it('should create a deletionRequest by passing required properties', () => { + const domainObject: DeletionLog = deletionLogFactory.build(); + + expect(domainObject instanceof DeletionLog).toEqual(true); + }); + }); + + describe('when passed a valid id', () => { + const setup = () => { + const domainObject: DeletionLog = deletionLogFactory.buildWithId(); + + return { domainObject }; + }; + + it('should set the id', () => { + const { domainObject } = setup(); + + const deletionLogDomainObject: DeletionLog = new DeletionLog(domainObject); + + expect(deletionLogDomainObject.id).toEqual(domainObject.id); + }); + }); + }); + + describe('getters', () => { + describe('When getters are used', () => { + const setup = () => { + const props = { + id: new ObjectId().toHexString(), + domain: DeletionDomainModel.USER, + operation: DeletionOperationModel.DELETE, + modifiedCount: 0, + deletedCount: 1, + deletionRequestId: new ObjectId().toHexString(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + const deletionLogDo = new DeletionLog(props); + + return { props, deletionLogDo }; + }; + it('getters should return proper values', () => { + const { props, deletionLogDo } = setup(); + + const gettersValues = { + id: deletionLogDo.id, + domain: deletionLogDo.domain, + operation: deletionLogDo.operation, + modifiedCount: deletionLogDo.modifiedCount, + deletedCount: deletionLogDo.deletedCount, + deletionRequestId: deletionLogDo.deletionRequestId, + createdAt: deletionLogDo.createdAt, + updatedAt: deletionLogDo.updatedAt, + }; + + expect(gettersValues).toEqual(props); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/domain/deletion-log.do.ts b/apps/server/src/modules/deletion/domain/deletion-log.do.ts new file mode 100644 index 00000000000..73e62b46055 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/deletion-log.do.ts @@ -0,0 +1,44 @@ +import { EntityId } from '@shared/domain/types'; +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { DeletionDomainModel } from './types/deletion-domain-model.enum'; +import { DeletionOperationModel } from './types/deletion-operation-model.enum'; + +export interface DeletionLogProps extends AuthorizableObject { + createdAt?: Date; + updatedAt?: Date; + domain: DeletionDomainModel; + operation?: DeletionOperationModel; + modifiedCount?: number; + deletedCount?: number; + deletionRequestId?: EntityId; +} + +export class DeletionLog extends DomainObject { + get createdAt(): Date | undefined { + return this.props.createdAt; + } + + get updatedAt(): Date | undefined { + return this.props.updatedAt; + } + + get domain(): DeletionDomainModel { + return this.props.domain; + } + + get operation(): DeletionOperationModel | undefined { + return this.props.operation; + } + + get modifiedCount(): number | undefined { + return this.props.modifiedCount; + } + + get deletedCount(): number | undefined { + return this.props.deletedCount; + } + + get deletionRequestId(): EntityId | undefined { + return this.props.deletionRequestId; + } +} diff --git a/apps/server/src/modules/deletion/domain/deletion-request.do.spec.ts b/apps/server/src/modules/deletion/domain/deletion-request.do.spec.ts new file mode 100644 index 00000000000..3c0eb608c87 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/deletion-request.do.spec.ts @@ -0,0 +1,69 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { DeletionRequest } from './deletion-request.do'; +import { DeletionDomainModel } from './types/deletion-domain-model.enum'; +import { deletionRequestFactory } from './testing/factory/deletion-request.factory'; +import { DeletionStatusModel } from './types/deletion-status-model.enum'; + +describe(DeletionRequest.name, () => { + describe('constructor', () => { + describe('When constructor is called', () => { + it('should create a deletionRequest by passing required properties', () => { + const domainObject: DeletionRequest = deletionRequestFactory.build(); + + expect(domainObject instanceof DeletionRequest).toEqual(true); + }); + }); + + describe('when passed a valid id', () => { + const setup = () => { + const domainObject: DeletionRequest = deletionRequestFactory.buildWithId(); + + return { domainObject }; + }; + + it('should set the id', () => { + const { domainObject } = setup(); + + const deletionRequestDomainObject: DeletionRequest = new DeletionRequest(domainObject); + + expect(deletionRequestDomainObject.id).toEqual(domainObject.id); + }); + }); + }); + + describe('getters', () => { + describe('When getters are used', () => { + const setup = () => { + const props = { + id: new ObjectId().toHexString(), + targetRefDomain: DeletionDomainModel.USER, + deleteAfter: new Date(), + targetRefId: new ObjectId().toHexString(), + status: DeletionStatusModel.REGISTERED, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const deletionRequestDo = new DeletionRequest(props); + + return { props, deletionRequestDo }; + }; + + it('getters should return proper values', () => { + const { props, deletionRequestDo } = setup(); + + const gettersValues = { + id: deletionRequestDo.id, + targetRefDomain: deletionRequestDo.targetRefDomain, + deleteAfter: deletionRequestDo.deleteAfter, + targetRefId: deletionRequestDo.targetRefId, + status: deletionRequestDo.status, + createdAt: deletionRequestDo.createdAt, + updatedAt: deletionRequestDo.updatedAt, + }; + + expect(gettersValues).toEqual(props); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/domain/deletion-request.do.ts b/apps/server/src/modules/deletion/domain/deletion-request.do.ts new file mode 100644 index 00000000000..e1a8b289ef0 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/deletion-request.do.ts @@ -0,0 +1,39 @@ +import { EntityId } from '@shared/domain/types'; +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { DeletionDomainModel } from './types/deletion-domain-model.enum'; +import { DeletionStatusModel } from './types/deletion-status-model.enum'; + +export interface DeletionRequestProps extends AuthorizableObject { + createdAt?: Date; + updatedAt?: Date; + targetRefDomain: DeletionDomainModel; + deleteAfter: Date; + targetRefId: EntityId; + status: DeletionStatusModel; +} + +export class DeletionRequest extends DomainObject { + get createdAt(): Date | undefined { + return this.props.createdAt; + } + + get updatedAt(): Date | undefined { + return this.props.updatedAt; + } + + get targetRefDomain(): DeletionDomainModel { + return this.props.targetRefDomain; + } + + get deleteAfter(): Date { + return this.props.deleteAfter; + } + + get targetRefId(): EntityId { + return this.props.targetRefId; + } + + get status(): DeletionStatusModel { + return this.props.status; + } +} diff --git a/apps/server/src/modules/deletion/domain/testing/factory/deletion-log.factory.ts b/apps/server/src/modules/deletion/domain/testing/factory/deletion-log.factory.ts new file mode 100644 index 00000000000..d83b2f44c8a --- /dev/null +++ b/apps/server/src/modules/deletion/domain/testing/factory/deletion-log.factory.ts @@ -0,0 +1,18 @@ +import { DoBaseFactory } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { DeletionLog, DeletionLogProps } from '../../deletion-log.do'; +import { DeletionOperationModel } from '../../types/deletion-operation-model.enum'; +import { DeletionDomainModel } from '../../types/deletion-domain-model.enum'; + +export const deletionLogFactory = DoBaseFactory.define(DeletionLog, () => { + return { + id: new ObjectId().toHexString(), + domain: DeletionDomainModel.USER, + operation: DeletionOperationModel.DELETE, + modifiedCount: 0, + deletedCount: 1, + deletionRequestId: new ObjectId().toHexString(), + createdAt: new Date(), + updatedAt: new Date(), + }; +}); diff --git a/apps/server/src/modules/deletion/domain/testing/factory/deletion-request.factory.ts b/apps/server/src/modules/deletion/domain/testing/factory/deletion-request.factory.ts new file mode 100644 index 00000000000..9f87bbc1cbf --- /dev/null +++ b/apps/server/src/modules/deletion/domain/testing/factory/deletion-request.factory.ts @@ -0,0 +1,28 @@ +import { DoBaseFactory } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { DeepPartial } from 'fishery'; +import { DeletionRequest, DeletionRequestProps } from '../../deletion-request.do'; +import { DeletionDomainModel } from '../../types/deletion-domain-model.enum'; +import { DeletionStatusModel } from '../../types/deletion-status-model.enum'; + +class DeletionRequestFactory extends DoBaseFactory { + withUserIds(id: string): this { + const params: DeepPartial = { + targetRefId: id, + }; + + return this.params(params); + } +} + +export const deletionRequestFactory = DeletionRequestFactory.define(DeletionRequest, () => { + return { + id: new ObjectId().toHexString(), + targetRefDomain: DeletionDomainModel.USER, + deleteAfter: new Date(), + targetRefId: new ObjectId().toHexString(), + status: DeletionStatusModel.REGISTERED, + createdAt: new Date(), + updatedAt: new Date(), + }; +}); diff --git a/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts b/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts new file mode 100644 index 00000000000..dbfc2e06d8d --- /dev/null +++ b/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts @@ -0,0 +1,11 @@ +export const enum DeletionDomainModel { + ACCOUNT = 'account', + CLASS = 'class', + COURSEGROUP = 'courseGroup', + COURSE = 'course', + FILE = 'file', + LESSONS = 'lessons', + PSEUDONYMS = 'pseudonyms', + TEAMS = 'teams', + USER = 'user', +} diff --git a/apps/server/src/modules/deletion/domain/types/deletion-operation-model.enum.ts b/apps/server/src/modules/deletion/domain/types/deletion-operation-model.enum.ts new file mode 100644 index 00000000000..675189e634b --- /dev/null +++ b/apps/server/src/modules/deletion/domain/types/deletion-operation-model.enum.ts @@ -0,0 +1,4 @@ +export const enum DeletionOperationModel { + DELETE = 'delete', + UPDATE = 'update', +} diff --git a/apps/server/src/modules/deletion/domain/types/deletion-status-model.enum.ts b/apps/server/src/modules/deletion/domain/types/deletion-status-model.enum.ts new file mode 100644 index 00000000000..5681d1be214 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/types/deletion-status-model.enum.ts @@ -0,0 +1,5 @@ +export const enum DeletionStatusModel { + FAILED = 'failed', + REGISTERED = 'registered', + SUCCESS = 'success', +} diff --git a/apps/server/src/modules/deletion/entity/deletion-log.entity.spec.ts b/apps/server/src/modules/deletion/entity/deletion-log.entity.spec.ts new file mode 100644 index 00000000000..4f9f098cbb3 --- /dev/null +++ b/apps/server/src/modules/deletion/entity/deletion-log.entity.spec.ts @@ -0,0 +1,60 @@ +import { setupEntities } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { DeletionLogEntity } from './deletion-log.entity'; +import { DeletionOperationModel } from '../domain/types/deletion-operation-model.enum'; +import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; + +describe(DeletionLogEntity.name, () => { + beforeAll(async () => { + await setupEntities(); + }); + + describe('constructor', () => { + describe('When constructor is called', () => { + const setup = () => { + const props = { + id: new ObjectId().toHexString(), + domain: DeletionDomainModel.USER, + operation: DeletionOperationModel.DELETE, + modifiedCount: 0, + deletedCount: 1, + deletionRequestId: new ObjectId(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + return { props }; + }; + it('should throw an error by empty constructor', () => { + // @ts-expect-error: Test case + const test = () => new DeletionLogEntity(); + expect(test).toThrow(); + }); + + it('should create a deletionLog by passing required properties', () => { + const { props } = setup(); + const entity: DeletionLogEntity = new DeletionLogEntity(props); + + expect(entity instanceof DeletionLogEntity).toEqual(true); + }); + + it(`should return a valid object with fields values set from the provided complete props object`, () => { + const { props } = setup(); + const entity: DeletionLogEntity = new DeletionLogEntity(props); + + const entityProps = { + id: entity.id, + domain: entity.domain, + operation: entity.operation, + modifiedCount: entity.modifiedCount, + deletedCount: entity.deletedCount, + deletionRequestId: entity.deletionRequestId, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }; + + expect(entityProps).toEqual(props); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/entity/deletion-log.entity.ts b/apps/server/src/modules/deletion/entity/deletion-log.entity.ts new file mode 100644 index 00000000000..8a9d2bab025 --- /dev/null +++ b/apps/server/src/modules/deletion/entity/deletion-log.entity.ts @@ -0,0 +1,67 @@ +import { Entity, Property } from '@mikro-orm/core'; +import { BaseEntityWithTimestamps, EntityId } from '@shared/domain'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; +import { DeletionOperationModel } from '../domain/types/deletion-operation-model.enum'; + +export interface DeletionLogEntityProps { + id?: EntityId; + domain: DeletionDomainModel; + operation?: DeletionOperationModel; + modifiedCount?: number; + deletedCount?: number; + deletionRequestId?: ObjectId; + createdAt?: Date; + updatedAt?: Date; +} + +@Entity({ tableName: 'deletionlogs' }) +export class DeletionLogEntity extends BaseEntityWithTimestamps { + @Property() + domain: DeletionDomainModel; + + @Property({ nullable: true }) + operation?: DeletionOperationModel; + + @Property({ nullable: true }) + modifiedCount?: number; + + @Property({ nullable: true }) + deletedCount?: number; + + @Property({ nullable: true }) + deletionRequestId?: ObjectId; + + constructor(props: DeletionLogEntityProps) { + super(); + if (props.id !== undefined) { + this.id = props.id; + } + + this.domain = props.domain; + + if (props.operation !== undefined) { + this.operation = props.operation; + } + + if (props.modifiedCount !== undefined) { + this.modifiedCount = props.modifiedCount; + } + + if (props.deletedCount !== undefined) { + this.deletedCount = props.deletedCount; + } + + if (props.deletionRequestId !== undefined) { + this.deletionRequestId = props.deletionRequestId; + } + + if (props.createdAt !== undefined) { + this.createdAt = props.createdAt; + } + + if (props.updatedAt !== undefined) { + this.updatedAt = props.updatedAt; + } + } +} diff --git a/apps/server/src/modules/deletion/entity/deletion-request.entity.spec.ts b/apps/server/src/modules/deletion/entity/deletion-request.entity.spec.ts new file mode 100644 index 00000000000..6a0e416d580 --- /dev/null +++ b/apps/server/src/modules/deletion/entity/deletion-request.entity.spec.ts @@ -0,0 +1,85 @@ +import { setupEntities } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { DeletionRequestEntity } from '@src/modules/deletion/entity/deletion-request.entity'; +import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; +import { DeletionStatusModel } from '../domain/types/deletion-status-model.enum'; + +describe(DeletionRequestEntity.name, () => { + beforeAll(async () => { + await setupEntities(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const setup = () => { + const props = { + id: new ObjectId().toHexString(), + targetRefDomain: DeletionDomainModel.USER, + deleteAfter: new Date(), + targetRefId: new ObjectId().toHexString(), + status: DeletionStatusModel.REGISTERED, + createdAt: new Date(), + updatedAt: new Date(), + }; + + return { props }; + }; + + describe('constructor', () => { + describe('When constructor is called', () => { + it('should throw an error by empty constructor', () => { + // @ts-expect-error: Test case + const test = () => new DeletionRequestEntity(); + expect(test).toThrow(); + }); + + it('should create a deletionRequest by passing required properties', () => { + const { props } = setup(); + const entity: DeletionRequestEntity = new DeletionRequestEntity(props); + + expect(entity instanceof DeletionRequestEntity).toEqual(true); + }); + + it(`should return a valid object with fields values set from the provided complete props object`, () => { + const { props } = setup(); + const entity: DeletionRequestEntity = new DeletionRequestEntity(props); + + const entityProps = { + id: entity.id, + targetRefDomain: entity.targetRefDomain, + deleteAfter: entity.deleteAfter, + targetRefId: entity.targetRefId, + status: entity.status, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }; + + expect(entityProps).toEqual(props); + }); + }); + }); + + describe('executed', () => { + it('should update status with value success', () => { + const { props } = setup(); + const entity: DeletionRequestEntity = new DeletionRequestEntity(props); + + entity.executed(); + + expect(entity.status).toEqual(DeletionStatusModel.SUCCESS); + }); + }); + + describe('failed', () => { + it('should update status with value failed', () => { + const { props } = setup(); + const entity: DeletionRequestEntity = new DeletionRequestEntity(props); + + entity.failed(); + + expect(entity.status).toEqual(DeletionStatusModel.FAILED); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/entity/deletion-request.entity.ts b/apps/server/src/modules/deletion/entity/deletion-request.entity.ts new file mode 100644 index 00000000000..150fed4d91e --- /dev/null +++ b/apps/server/src/modules/deletion/entity/deletion-request.entity.ts @@ -0,0 +1,60 @@ +import { Entity, Index, Property } from '@mikro-orm/core'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; +import { EntityId } from '@shared/domain'; +import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; +import { DeletionStatusModel } from '../domain/types/deletion-status-model.enum'; + +export interface DeletionRequestEntityProps { + id?: EntityId; + targetRefDomain: DeletionDomainModel; + deleteAfter: Date; + targetRefId: EntityId; + status: DeletionStatusModel; + createdAt?: Date; + updatedAt?: Date; +} + +@Entity({ tableName: 'deletionrequests' }) +@Index({ properties: ['targetRefId', 'targetRefDomain'] }) +export class DeletionRequestEntity extends BaseEntityWithTimestamps { + @Property() + deleteAfter: Date; + + @Property() + targetRefId: EntityId; + + @Property() + targetRefDomain: DeletionDomainModel; + + @Property() + @Index() + status: DeletionStatusModel; + + constructor(props: DeletionRequestEntityProps) { + super(); + if (props.id !== undefined) { + this.id = props.id; + } + + this.targetRefDomain = props.targetRefDomain; + this.deleteAfter = props.deleteAfter; + this.targetRefId = props.targetRefId; + this.status = props.status; + + if (props.createdAt !== undefined) { + this.createdAt = props.createdAt; + } + + if (props.updatedAt !== undefined) { + this.updatedAt = props.updatedAt; + } + } + + public executed(): void { + this.status = DeletionStatusModel.SUCCESS; + } + + public failed(): void { + this.status = DeletionStatusModel.FAILED; + } +} diff --git a/apps/server/src/modules/deletion/entity/index.ts b/apps/server/src/modules/deletion/entity/index.ts new file mode 100644 index 00000000000..7e3e31dcd19 --- /dev/null +++ b/apps/server/src/modules/deletion/entity/index.ts @@ -0,0 +1,2 @@ +export * from './deletion-request.entity'; +export * from './deletion-log.entity'; diff --git a/apps/server/src/modules/deletion/entity/testing/factory/deletion-log.entity.factory.ts b/apps/server/src/modules/deletion/entity/testing/factory/deletion-log.entity.factory.ts new file mode 100644 index 00000000000..897fba6820a --- /dev/null +++ b/apps/server/src/modules/deletion/entity/testing/factory/deletion-log.entity.factory.ts @@ -0,0 +1,21 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory } from '@shared/testing'; +import { DeletionLogEntity, DeletionLogEntityProps } from '../../deletion-log.entity'; +import { DeletionOperationModel } from '../../../domain/types/deletion-operation-model.enum'; +import { DeletionDomainModel } from '../../../domain/types/deletion-domain-model.enum'; + +export const deletionLogEntityFactory = BaseFactory.define( + DeletionLogEntity, + () => { + return { + id: new ObjectId().toHexString(), + domain: DeletionDomainModel.USER, + operation: DeletionOperationModel.DELETE, + modifiedCount: 0, + deletedCount: 1, + deletionRequestId: new ObjectId(), + createdAt: new Date(), + updatedAt: new Date(), + }; + } +); diff --git a/apps/server/src/modules/deletion/entity/testing/factory/deletion-request.entity.factory.ts b/apps/server/src/modules/deletion/entity/testing/factory/deletion-request.entity.factory.ts new file mode 100644 index 00000000000..3ccba779e3e --- /dev/null +++ b/apps/server/src/modules/deletion/entity/testing/factory/deletion-request.entity.factory.ts @@ -0,0 +1,20 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory } from '@shared/testing'; +import { DeletionStatusModel } from '../../../domain/types/deletion-status-model.enum'; +import { DeletionRequestEntity, DeletionRequestEntityProps } from '../../deletion-request.entity'; +import { DeletionDomainModel } from '../../../domain/types/deletion-domain-model.enum'; + +export const deletionRequestEntityFactory = BaseFactory.define( + DeletionRequestEntity, + () => { + return { + id: new ObjectId().toHexString(), + targetRefDomain: DeletionDomainModel.USER, + deleteAfter: new Date(), + targetRefId: new ObjectId().toHexString(), + status: DeletionStatusModel.REGISTERED, + createdAt: new Date(), + updatedAt: new Date(), + }; + } +); diff --git a/apps/server/src/modules/deletion/index.ts b/apps/server/src/modules/deletion/index.ts new file mode 100644 index 00000000000..bd89c1e8d84 --- /dev/null +++ b/apps/server/src/modules/deletion/index.ts @@ -0,0 +1,2 @@ +export * from './deletion.module'; +export * from './services'; diff --git a/apps/server/src/modules/deletion/repo/deletion-log.repo.spec.ts b/apps/server/src/modules/deletion/repo/deletion-log.repo.spec.ts new file mode 100644 index 00000000000..5bc151c3541 --- /dev/null +++ b/apps/server/src/modules/deletion/repo/deletion-log.repo.spec.ts @@ -0,0 +1,190 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Test } from '@nestjs/testing'; +import { TestingModule } from '@nestjs/testing/testing-module'; +import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { cleanupCollections } from '@shared/testing'; +import { DeletionLogMapper } from './mapper'; +import { DeletionLogEntity } from '../entity'; +import { DeletionLogRepo } from './deletion-log.repo'; +import { deletionLogFactory } from '../domain/testing/factory/deletion-log.factory'; +import { DeletionLog } from '../domain/deletion-log.do'; +import { deletionLogEntityFactory } from '../entity/testing/factory/deletion-log.entity.factory'; + +describe(DeletionLogRepo.name, () => { + let module: TestingModule; + let repo: DeletionLogRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + MongoMemoryDatabaseModule.forRoot({ + entities: [DeletionLogEntity], + }), + ], + providers: [DeletionLogRepo, DeletionLogMapper], + }).compile(); + + repo = module.get(DeletionLogRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('defined', () => { + it('repo should be defined', () => { + expect(repo).toBeDefined(); + }); + + it('entity manager should be defined', () => { + expect(em).toBeDefined(); + }); + + it('should implement entityName getter', () => { + expect(repo.entityName).toBe(DeletionLogEntity); + }); + }); + + describe('create deletionLog', () => { + describe('when deletionLog is new', () => { + const setup = () => { + const domainObject: DeletionLog = deletionLogFactory.build(); + const deletionLogId = domainObject.id; + + const expectedDomainObject = { + id: domainObject.id, + domain: domainObject.domain, + operation: domainObject.operation, + modifiedCount: domainObject.modifiedCount, + deletedCount: domainObject.deletedCount, + deletionRequestId: domainObject.deletionRequestId, + createdAt: domainObject.createdAt, + updatedAt: domainObject.updatedAt, + }; + + return { domainObject, deletionLogId, expectedDomainObject }; + }; + it('should create a new deletionLog', async () => { + const { domainObject, deletionLogId, expectedDomainObject } = setup(); + await repo.create(domainObject); + + const result = await repo.findById(deletionLogId); + + expect(result).toEqual(expect.objectContaining(expectedDomainObject)); + }); + }); + }); + + describe('findById', () => { + describe('when searching by Id', () => { + const setup = async () => { + // Test deletionLog entity + const entity: DeletionLogEntity = deletionLogEntityFactory.build(); + await em.persistAndFlush(entity); + + const expectedDeletionLog = { + id: entity.id, + domain: entity.domain, + operation: entity.operation, + modifiedCount: entity.modifiedCount, + deletedCount: entity.deletedCount, + deletionRequestId: entity.deletionRequestId?.toHexString(), + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }; + + return { + entity, + expectedDeletionLog, + }; + }; + + it('should find the deletionRequest', async () => { + const { entity, expectedDeletionLog } = await setup(); + + const result: DeletionLog = await repo.findById(entity.id); + + // Verify explicit fields. + expect(result).toEqual(expect.objectContaining(expectedDeletionLog)); + }); + }); + }); + + describe('findAllByDeletionRequestId', () => { + describe('when there is no deletionLog for deletionRequestId', () => { + it('should return empty array', async () => { + const deletionRequestId = new ObjectId().toHexString(); + const result = await repo.findAllByDeletionRequestId(deletionRequestId); + + expect(result).toEqual([]); + }); + }); + + describe('when searching by deletionRequestId', () => { + const setup = async () => { + const deletionRequest1Id = new ObjectId(); + const deletionRequest2Id = new ObjectId(); + const deletionLogEntity1: DeletionLogEntity = deletionLogEntityFactory.build({ + deletionRequestId: deletionRequest1Id, + }); + const deletionLogEntity2: DeletionLogEntity = deletionLogEntityFactory.build({ + deletionRequestId: deletionRequest1Id, + }); + const deletionLogEntity3: DeletionLogEntity = deletionLogEntityFactory.build({ + deletionRequestId: deletionRequest2Id, + }); + + await em.persistAndFlush([deletionLogEntity1, deletionLogEntity2, deletionLogEntity3]); + em.clear(); + + const expectedArray = [ + { + id: deletionLogEntity1.id, + domain: deletionLogEntity1.domain, + operation: deletionLogEntity1.operation, + deletionRequestId: deletionLogEntity1.deletionRequestId?.toHexString(), + modifiedCount: deletionLogEntity1.modifiedCount, + deletedCount: deletionLogEntity1.deletedCount, + createdAt: deletionLogEntity1.createdAt, + updatedAt: deletionLogEntity1.updatedAt, + }, + { + id: deletionLogEntity2.id, + domain: deletionLogEntity2.domain, + operation: deletionLogEntity2.operation, + deletionRequestId: deletionLogEntity2.deletionRequestId?.toHexString(), + modifiedCount: deletionLogEntity2.modifiedCount, + deletedCount: deletionLogEntity2.deletedCount, + createdAt: deletionLogEntity2.createdAt, + updatedAt: deletionLogEntity2.updatedAt, + }, + ]; + + return { deletionLogEntity3, deletionRequest1Id, expectedArray }; + }; + + it('should find deletionRequests with deleteAfter smaller then today', async () => { + const { deletionLogEntity3, deletionRequest1Id, expectedArray } = await setup(); + + const results = await repo.findAllByDeletionRequestId(deletionRequest1Id.toHexString()); + + expect(results.length).toEqual(2); + + // Verify explicit fields. + expect(results).toEqual( + expect.arrayContaining([expect.objectContaining(expectedArray[0]), expect.objectContaining(expectedArray[1])]) + ); + + const result: DeletionLog = await repo.findById(deletionLogEntity3.id); + + expect(result.id).toEqual(deletionLogEntity3.id); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/repo/deletion-log.repo.ts b/apps/server/src/modules/deletion/repo/deletion-log.repo.ts new file mode 100644 index 00000000000..d71032eb124 --- /dev/null +++ b/apps/server/src/modules/deletion/repo/deletion-log.repo.ts @@ -0,0 +1,41 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { DeletionLog } from '../domain/deletion-log.do'; +import { DeletionLogEntity } from '../entity/deletion-log.entity'; +import { DeletionLogMapper } from './mapper/deletion-log.mapper'; + +@Injectable() +export class DeletionLogRepo { + constructor(private readonly em: EntityManager) {} + + get entityName() { + return DeletionLogEntity; + } + + async findById(deletionLogId: EntityId): Promise { + const deletionLog: DeletionLogEntity = await this.em.findOneOrFail(DeletionLogEntity, { + id: deletionLogId, + }); + + const mapped: DeletionLog = DeletionLogMapper.mapToDO(deletionLog); + + return mapped; + } + + async findAllByDeletionRequestId(deletionRequestId: EntityId): Promise { + const deletionLogEntities: DeletionLogEntity[] = await this.em.find(DeletionLogEntity, { + deletionRequestId: new ObjectId(deletionRequestId), + }); + + const mapped: DeletionLog[] = DeletionLogMapper.mapToDOs(deletionLogEntities); + + return mapped; + } + + async create(deletionLog: DeletionLog): Promise { + const deletionLogEntity: DeletionLogEntity = DeletionLogMapper.mapToEntity(deletionLog); + this.em.persist(deletionLogEntity); + await this.em.flush(); + } +} diff --git a/apps/server/src/modules/deletion/repo/deletion-request-scope.ts b/apps/server/src/modules/deletion/repo/deletion-request-scope.ts new file mode 100644 index 00000000000..202bc09a887 --- /dev/null +++ b/apps/server/src/modules/deletion/repo/deletion-request-scope.ts @@ -0,0 +1,17 @@ +import { Scope } from '@shared/repo'; +import { DeletionRequestEntity } from '../entity'; +import { DeletionStatusModel } from '../domain/types/deletion-status-model.enum'; + +export class DeletionRequestScope extends Scope { + byDeleteAfter(currentDate: Date): DeletionRequestScope { + this.addQuery({ deleteAfter: { $lt: currentDate } }); + + return this; + } + + byStatus(): DeletionRequestScope { + this.addQuery({ status: [DeletionStatusModel.REGISTERED, DeletionStatusModel.FAILED] }); + + return this; + } +} diff --git a/apps/server/src/modules/deletion/repo/deletion-request.repo.spec.ts b/apps/server/src/modules/deletion/repo/deletion-request.repo.spec.ts new file mode 100644 index 00000000000..579dc0ec403 --- /dev/null +++ b/apps/server/src/modules/deletion/repo/deletion-request.repo.spec.ts @@ -0,0 +1,342 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Test } from '@nestjs/testing'; +import { TestingModule } from '@nestjs/testing/testing-module'; +import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { cleanupCollections } from '@shared/testing'; +import { DeletionRequestMapper } from './mapper'; +import { DeletionRequestRepo } from './deletion-request.repo'; +import { DeletionRequestEntity } from '../entity'; +import { DeletionRequest } from '../domain/deletion-request.do'; +import { deletionRequestEntityFactory } from '../entity/testing/factory/deletion-request.entity.factory'; +import { deletionRequestFactory } from '../domain/testing/factory/deletion-request.factory'; +import { DeletionStatusModel } from '../domain/types/deletion-status-model.enum'; + +describe(DeletionRequestRepo.name, () => { + let module: TestingModule; + let repo: DeletionRequestRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + MongoMemoryDatabaseModule.forRoot({ + entities: [DeletionRequestEntity], + }), + ], + providers: [DeletionRequestRepo, DeletionRequestMapper], + }).compile(); + + repo = module.get(DeletionRequestRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('defined', () => { + it('repo should be defined', () => { + expect(repo).toBeDefined(); + }); + + it('entity manager should be defined', () => { + expect(em).toBeDefined(); + }); + + it('should implement entityName getter', () => { + expect(repo.entityName).toBe(DeletionRequestEntity); + }); + }); + + describe('create deletionRequest', () => { + describe('when deletionRequest is new', () => { + it('should create a new deletionRequest', async () => { + const domainObject: DeletionRequest = deletionRequestFactory.build(); + const deletionRequestId = domainObject.id; + await repo.create(domainObject); + + const result = await repo.findById(deletionRequestId); + + expect(result).toEqual(domainObject); + }); + }); + }); + + describe('findById', () => { + describe('when searching by Id', () => { + const setup = async () => { + const userId = new ObjectId().toHexString(); + + const entity: DeletionRequestEntity = deletionRequestEntityFactory.build({ targetRefId: userId }); + await em.persistAndFlush(entity); + + const expectedDeletionRequest = { + id: entity.id, + targetRefDomain: entity.targetRefDomain, + deleteAfter: entity.deleteAfter, + targetRefId: entity.targetRefId, + status: entity.status, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }; + + return { + entity, + expectedDeletionRequest, + }; + }; + + it('should find the deletionRequest', async () => { + const { entity, expectedDeletionRequest } = await setup(); + + const result: DeletionRequest = await repo.findById(entity.id); + + // Verify explicit fields. + expect(result).toEqual(expect.objectContaining(expectedDeletionRequest)); + }); + }); + }); + + describe('findAllItemsToExecution', () => { + describe('when there is no deletionRequest for execution', () => { + it('should return empty array', async () => { + const result = await repo.findAllItemsToExecution(); + + expect(result).toEqual([]); + }); + }); + + describe('when there are deletionRequests for execution', () => { + const setup = async () => { + const dateInFuture = new Date(); + dateInFuture.setDate(dateInFuture.getDate() + 30); + const deletionRequestEntity1: DeletionRequestEntity = deletionRequestEntityFactory.build({ + createdAt: new Date(2023, 7, 1), + deleteAfter: new Date(2023, 8, 1), + status: DeletionStatusModel.SUCCESS, + }); + const deletionRequestEntity2: DeletionRequestEntity = deletionRequestEntityFactory.build({ + createdAt: new Date(2023, 7, 1), + deleteAfter: new Date(2023, 8, 1), + status: DeletionStatusModel.FAILED, + }); + const deletionRequestEntity3: DeletionRequestEntity = deletionRequestEntityFactory.build({ + createdAt: new Date(2023, 8, 1), + deleteAfter: new Date(2023, 9, 1), + }); + const deletionRequestEntity4: DeletionRequestEntity = deletionRequestEntityFactory.build({ + createdAt: new Date(2023, 9, 1), + deleteAfter: new Date(2023, 10, 1), + }); + const deletionRequestEntity5: DeletionRequestEntity = deletionRequestEntityFactory.build({ + deleteAfter: dateInFuture, + }); + + await em.persistAndFlush([ + deletionRequestEntity1, + deletionRequestEntity2, + deletionRequestEntity3, + deletionRequestEntity4, + deletionRequestEntity5, + ]); + em.clear(); + + const expectedArray = [ + { + id: deletionRequestEntity4.id, + targetRefDomain: deletionRequestEntity4.targetRefDomain, + deleteAfter: deletionRequestEntity4.deleteAfter, + targetRefId: deletionRequestEntity4.targetRefId, + status: deletionRequestEntity4.status, + createdAt: deletionRequestEntity4.createdAt, + updatedAt: deletionRequestEntity4.updatedAt, + }, + { + id: deletionRequestEntity3.id, + targetRefDomain: deletionRequestEntity3.targetRefDomain, + deleteAfter: deletionRequestEntity3.deleteAfter, + targetRefId: deletionRequestEntity3.targetRefId, + status: deletionRequestEntity3.status, + createdAt: deletionRequestEntity3.createdAt, + updatedAt: deletionRequestEntity3.updatedAt, + }, + { + id: deletionRequestEntity2.id, + targetRefDomain: deletionRequestEntity2.targetRefDomain, + deleteAfter: deletionRequestEntity2.deleteAfter, + targetRefId: deletionRequestEntity2.targetRefId, + status: deletionRequestEntity2.status, + createdAt: deletionRequestEntity2.createdAt, + updatedAt: deletionRequestEntity2.updatedAt, + }, + ]; + + return { deletionRequestEntity1, deletionRequestEntity5, expectedArray }; + }; + + it('should find deletionRequests with deleteAfter smaller then today and status with value registered or failed', async () => { + const { deletionRequestEntity1, deletionRequestEntity5, expectedArray } = await setup(); + + const results = await repo.findAllItemsToExecution(); + + expect(results.length).toEqual(3); + + // Verify explicit fields. + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining(expectedArray[0]), + expect.objectContaining(expectedArray[1]), + expect.objectContaining(expectedArray[2]), + ]) + ); + + const result1: DeletionRequest = await repo.findById(deletionRequestEntity1.id); + + expect(result1.id).toEqual(deletionRequestEntity1.id); + + const result5: DeletionRequest = await repo.findById(deletionRequestEntity5.id); + + expect(result5.id).toEqual(deletionRequestEntity5.id); + }); + + it('should find deletionRequests to execute with limit = 2', async () => { + const { expectedArray } = await setup(); + + const results = await repo.findAllItemsToExecution(2); + + expect(results.length).toEqual(2); + + // Verify explicit fields. + expect(results).toEqual( + expect.arrayContaining([expect.objectContaining(expectedArray[0]), expect.objectContaining(expectedArray[1])]) + ); + }); + }); + }); + + describe('update', () => { + describe('when updating deletionRequest', () => { + const setup = async () => { + const userId = new ObjectId().toHexString(); + + const entity: DeletionRequestEntity = deletionRequestEntityFactory.build({ targetRefId: userId }); + await em.persistAndFlush(entity); + + // Arrange expected DeletionRequestEntity after changing status + entity.status = DeletionStatusModel.SUCCESS; + const deletionRequestToUpdate = DeletionRequestMapper.mapToDO(entity); + + return { + entity, + deletionRequestToUpdate, + }; + }; + + it('should update the deletionRequest', async () => { + const { entity, deletionRequestToUpdate } = await setup(); + + await repo.update(deletionRequestToUpdate); + + const result: DeletionRequest = await repo.findById(entity.id); + + expect(result.status).toEqual(entity.status); + }); + }); + }); + + describe('markDeletionRequestAsFailed', () => { + describe('when mark deletionRequest as failed', () => { + const setup = async () => { + const userId = new ObjectId().toHexString(); + + const entity: DeletionRequestEntity = deletionRequestEntityFactory.build({ targetRefId: userId }); + await em.persistAndFlush(entity); + + return { entity }; + }; + + it('should update the deletionRequest', async () => { + const { entity } = await setup(); + + const result = await repo.markDeletionRequestAsFailed(entity.id); + + expect(result).toBe(true); + }); + + it('should update the deletionRequest', async () => { + const { entity } = await setup(); + + await repo.markDeletionRequestAsFailed(entity.id); + + const result: DeletionRequest = await repo.findById(entity.id); + + expect(result.status).toEqual(DeletionStatusModel.FAILED); + }); + }); + }); + + describe('markDeletionRequestAsExecuted', () => { + describe('when mark deletionRequest as executed', () => { + const setup = async () => { + const userId = new ObjectId().toHexString(); + + const entity: DeletionRequestEntity = deletionRequestEntityFactory.build({ targetRefId: userId }); + await em.persistAndFlush(entity); + + return { entity }; + }; + + it('should update the deletionRequest', async () => { + const { entity } = await setup(); + + const result = await repo.markDeletionRequestAsExecuted(entity.id); + + expect(result).toBe(true); + }); + + it('should update the deletionRequest', async () => { + const { entity } = await setup(); + + await repo.markDeletionRequestAsExecuted(entity.id); + + const result: DeletionRequest = await repo.findById(entity.id); + + expect(result.status).toEqual(DeletionStatusModel.SUCCESS); + }); + }); + }); + + describe('deleteById', () => { + describe('when deleting deletionRequest exists', () => { + const setup = async () => { + const userId = new ObjectId().toHexString(); + const entity: DeletionRequestEntity = deletionRequestEntityFactory.build({ targetRefId: userId }); + const deletionRequestId = entity.id; + await em.persistAndFlush(entity); + em.clear(); + + return { deletionRequestId }; + }; + + it('should delete the deletionRequest with deletionRequestId', async () => { + const { deletionRequestId } = await setup(); + + await repo.deleteById(deletionRequestId); + + expect(await em.findOne(DeletionRequestEntity, { id: deletionRequestId })).toBeNull(); + }); + + it('should return true', async () => { + const { deletionRequestId } = await setup(); + + const result: boolean = await repo.deleteById(deletionRequestId); + + expect(result).toEqual(true); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/repo/deletion-request.repo.ts b/apps/server/src/modules/deletion/repo/deletion-request.repo.ts new file mode 100644 index 00000000000..b24cf792f01 --- /dev/null +++ b/apps/server/src/modules/deletion/repo/deletion-request.repo.ts @@ -0,0 +1,86 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { EntityId, SortOrder } from '@shared/domain'; +import { DeletionRequest } from '../domain/deletion-request.do'; +import { DeletionRequestEntity } from '../entity'; +import { DeletionRequestMapper } from './mapper/deletion-request.mapper'; +import { DeletionRequestScope } from './deletion-request-scope'; + +@Injectable() +export class DeletionRequestRepo { + constructor(private readonly em: EntityManager) {} + + get entityName() { + return DeletionRequestEntity; + } + + async findById(deletionRequestId: EntityId): Promise { + const deletionRequest: DeletionRequestEntity = await this.em.findOneOrFail(DeletionRequestEntity, { + id: deletionRequestId, + }); + + const mapped: DeletionRequest = DeletionRequestMapper.mapToDO(deletionRequest); + + return mapped; + } + + async create(deletionRequest: DeletionRequest): Promise { + const deletionRequestEntity = DeletionRequestMapper.mapToEntity(deletionRequest); + this.em.persist(deletionRequestEntity); + await this.em.flush(); + } + + async findAllItemsToExecution(limit?: number): Promise { + const currentDate = new Date(); + const scope = new DeletionRequestScope().byDeleteAfter(currentDate).byStatus(); + const order = { createdAt: SortOrder.desc }; + + const [deletionRequestEntities] = await this.em.findAndCount(DeletionRequestEntity, scope.query, { + limit, + orderBy: order, + }); + + const mapped: DeletionRequest[] = deletionRequestEntities.map((entity) => DeletionRequestMapper.mapToDO(entity)); + + return mapped; + } + + async update(deletionRequest: DeletionRequest): Promise { + const deletionRequestEntity = DeletionRequestMapper.mapToEntity(deletionRequest); + const referencedEntity = this.em.getReference(DeletionRequestEntity, deletionRequestEntity.id); + + await this.em.persistAndFlush(referencedEntity); + } + + async markDeletionRequestAsExecuted(deletionRequestId: EntityId): Promise { + const deletionRequest: DeletionRequestEntity = await this.em.findOneOrFail(DeletionRequestEntity, { + id: deletionRequestId, + }); + + deletionRequest.executed(); + await this.em.persistAndFlush(deletionRequest); + + return true; + } + + async markDeletionRequestAsFailed(deletionRequestId: EntityId): Promise { + const deletionRequest: DeletionRequestEntity = await this.em.findOneOrFail(DeletionRequestEntity, { + id: deletionRequestId, + }); + + deletionRequest.failed(); + await this.em.persistAndFlush(deletionRequest); + + return true; + } + + async deleteById(deletionRequestId: EntityId): Promise { + const entity: DeletionRequestEntity | null = await this.em.findOneOrFail(DeletionRequestEntity, { + id: deletionRequestId, + }); + + await this.em.removeAndFlush(entity); + + return true; + } +} diff --git a/apps/server/src/modules/deletion/repo/index.ts b/apps/server/src/modules/deletion/repo/index.ts new file mode 100644 index 00000000000..68860c00a79 --- /dev/null +++ b/apps/server/src/modules/deletion/repo/index.ts @@ -0,0 +1,2 @@ +export * from './deletion-log.repo'; +export * from './deletion-request.repo'; diff --git a/apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.spec.ts b/apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.spec.ts new file mode 100644 index 00000000000..a5823f5ce32 --- /dev/null +++ b/apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.spec.ts @@ -0,0 +1,162 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { deletionLogEntityFactory } from '../../entity/testing/factory/deletion-log.entity.factory'; +import { DeletionLogMapper } from './deletion-log.mapper'; +import { DeletionLog } from '../../domain/deletion-log.do'; +import { deletionLogFactory } from '../../domain/testing/factory/deletion-log.factory'; +import { DeletionLogEntity } from '../../entity'; + +describe(DeletionLogMapper.name, () => { + describe('mapToDO', () => { + describe('When entity is mapped for domainObject', () => { + const setup = () => { + const entity = deletionLogEntityFactory.build(); + + const expectedDomainObject = new DeletionLog({ + id: entity.id, + domain: entity.domain, + operation: entity.operation, + deletionRequestId: entity.deletionRequestId?.toHexString(), + modifiedCount: entity.modifiedCount, + deletedCount: entity.deletedCount, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }); + + return { entity, expectedDomainObject }; + }; + it('should properly map the entity to the domain object', () => { + const { entity, expectedDomainObject } = setup(); + + const domainObject = DeletionLogMapper.mapToDO(entity); + + expect(domainObject).toEqual(expectedDomainObject); + }); + }); + }); + + describe('mapToDOs', () => { + describe('When empty entities array is mapped for an empty domainObjects array', () => { + it('should return empty domain objects array for an empty entities array', () => { + const domainObjects = DeletionLogMapper.mapToDOs([]); + + expect(domainObjects).toEqual([]); + }); + }); + + describe('When entities array is mapped for domainObjects array', () => { + const setup = () => { + const entities = [deletionLogEntityFactory.build()]; + + const expectedDomainObjects = entities.map( + (entity) => + new DeletionLog({ + id: entity.id, + domain: entity.domain, + operation: entity.operation, + deletionRequestId: entity.deletionRequestId?.toHexString(), + modifiedCount: entity.modifiedCount, + deletedCount: entity.deletedCount, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }) + ); + + return { entities, expectedDomainObjects }; + }; + it('should properly map the entities to the domain objects', () => { + const { entities, expectedDomainObjects } = setup(); + + const domainObjects = DeletionLogMapper.mapToDOs(entities); + + expect(domainObjects).toEqual(expectedDomainObjects); + }); + }); + }); + + describe('mapToEntity', () => { + describe('When domainObject is mapped for entity', () => { + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date()); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + const setup = () => { + const domainObject = deletionLogFactory.build(); + + const expectedEntities = new DeletionLogEntity({ + id: domainObject.id, + domain: domainObject.domain, + operation: domainObject.operation, + deletionRequestId: new ObjectId(domainObject.deletionRequestId), + modifiedCount: domainObject.modifiedCount, + deletedCount: domainObject.deletedCount, + createdAt: domainObject.createdAt, + updatedAt: domainObject.updatedAt, + }); + + return { domainObject, expectedEntities }; + }; + + it('should properly map the domainObject to the entity', () => { + const { domainObject, expectedEntities } = setup(); + + const entities = DeletionLogMapper.mapToEntity(domainObject); + + expect(entities).toEqual(expectedEntities); + }); + }); + }); + + describe('mapToEntities', () => { + describe('When empty domainObjects array is mapped for an entities array', () => { + it('should return empty entities array for an empty domain objects array', () => { + const entities = DeletionLogMapper.mapToEntities([]); + + expect(entities).toEqual([]); + }); + }); + + describe('When domainObjects array is mapped for entities array', () => { + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date()); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + const setup = () => { + const domainObjects = [deletionLogFactory.build()]; + + const expectedEntities = domainObjects.map( + (domainObject) => + new DeletionLogEntity({ + id: domainObject.id, + domain: domainObject.domain, + operation: domainObject.operation, + deletionRequestId: new ObjectId(domainObject.deletionRequestId), + modifiedCount: domainObject.modifiedCount, + deletedCount: domainObject.deletedCount, + createdAt: domainObject.createdAt, + updatedAt: domainObject.updatedAt, + }) + ); + + return { domainObjects, expectedEntities }; + }; + + it('should properly map the domainObjects to the entities', () => { + const { domainObjects, expectedEntities } = setup(); + + const entities = DeletionLogMapper.mapToEntities(domainObjects); + + expect(entities).toEqual(expectedEntities); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.ts b/apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.ts new file mode 100644 index 00000000000..820cd9d87c0 --- /dev/null +++ b/apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.ts @@ -0,0 +1,39 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { DeletionLogEntity } from '../../entity/deletion-log.entity'; +import { DeletionLog } from '../../domain/deletion-log.do'; + +export class DeletionLogMapper { + static mapToDO(entity: DeletionLogEntity): DeletionLog { + return new DeletionLog({ + id: entity.id, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + domain: entity.domain, + operation: entity.operation, + modifiedCount: entity.modifiedCount, + deletedCount: entity.deletedCount, + deletionRequestId: entity.deletionRequestId?.toHexString(), + }); + } + + static mapToEntity(domainObject: DeletionLog): DeletionLogEntity { + return new DeletionLogEntity({ + id: domainObject.id, + createdAt: domainObject.createdAt, + updatedAt: domainObject.updatedAt, + domain: domainObject.domain, + operation: domainObject.operation, + modifiedCount: domainObject.modifiedCount, + deletedCount: domainObject.deletedCount, + deletionRequestId: new ObjectId(domainObject.deletionRequestId), + }); + } + + static mapToDOs(entities: DeletionLogEntity[]): DeletionLog[] { + return entities.map((entity) => this.mapToDO(entity)); + } + + static mapToEntities(domainObjects: DeletionLog[]): DeletionLogEntity[] { + return domainObjects.map((domainObject) => this.mapToEntity(domainObject)); + } +} diff --git a/apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.spec.ts b/apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.spec.ts new file mode 100644 index 00000000000..4e880aab54e --- /dev/null +++ b/apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.spec.ts @@ -0,0 +1,71 @@ +import { DeletionRequest } from '../../domain/deletion-request.do'; +import { deletionRequestFactory } from '../../domain/testing/factory/deletion-request.factory'; +import { DeletionRequestEntity } from '../../entity'; +import { deletionRequestEntityFactory } from '../../entity/testing/factory/deletion-request.entity.factory'; +import { DeletionRequestMapper } from './deletion-request.mapper'; + +describe(DeletionRequestMapper.name, () => { + describe('mapToDO', () => { + describe('When entity is mapped for domainObject', () => { + const setup = () => { + const entity = deletionRequestEntityFactory.build(); + + const expectedDomainObject = new DeletionRequest({ + id: entity.id, + targetRefDomain: entity.targetRefDomain, + deleteAfter: entity.deleteAfter, + targetRefId: entity.targetRefId, + status: entity.status, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }); + + return { entity, expectedDomainObject }; + }; + + it('should properly map the entity to the domain object', () => { + const { entity, expectedDomainObject } = setup(); + + const domainObject = DeletionRequestMapper.mapToDO(entity); + + expect(domainObject).toEqual(expectedDomainObject); + }); + }); + }); + + describe('mapToEntity', () => { + describe('When domainObject is mapped for entity', () => { + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date()); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + const setup = () => { + const domainObject = deletionRequestFactory.build(); + + const expectedEntity = new DeletionRequestEntity({ + id: domainObject.id, + targetRefDomain: domainObject.targetRefDomain, + deleteAfter: domainObject.deleteAfter, + targetRefId: domainObject.targetRefId, + status: domainObject.status, + createdAt: domainObject.createdAt, + updatedAt: domainObject.updatedAt, + }); + + return { domainObject, expectedEntity }; + }; + + it('should properly map the domainObject to the entity', () => { + const { domainObject, expectedEntity } = setup(); + + const entity = DeletionRequestMapper.mapToEntity(domainObject); + + expect(entity).toEqual(expectedEntity); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.ts b/apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.ts new file mode 100644 index 00000000000..fd6c273011f --- /dev/null +++ b/apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.ts @@ -0,0 +1,28 @@ +import { DeletionRequest } from '../../domain/deletion-request.do'; +import { DeletionRequestEntity } from '../../entity'; + +export class DeletionRequestMapper { + static mapToDO(entity: DeletionRequestEntity): DeletionRequest { + return new DeletionRequest({ + id: entity.id, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + targetRefDomain: entity.targetRefDomain, + deleteAfter: entity.deleteAfter, + targetRefId: entity.targetRefId, + status: entity.status, + }); + } + + static mapToEntity(domainObject: DeletionRequest): DeletionRequestEntity { + return new DeletionRequestEntity({ + id: domainObject.id, + targetRefDomain: domainObject.targetRefDomain, + deleteAfter: domainObject.deleteAfter, + targetRefId: domainObject.targetRefId, + createdAt: domainObject.createdAt, + updatedAt: domainObject.updatedAt, + status: domainObject.status, + }); + } +} diff --git a/apps/server/src/modules/deletion/repo/mapper/index.ts b/apps/server/src/modules/deletion/repo/mapper/index.ts new file mode 100644 index 00000000000..0407135b228 --- /dev/null +++ b/apps/server/src/modules/deletion/repo/mapper/index.ts @@ -0,0 +1,2 @@ +export * from './deletion-request.mapper'; +export * from './deletion-log.mapper'; diff --git a/apps/server/src/modules/deletion/services/deletion-log.service.spec.ts b/apps/server/src/modules/deletion/services/deletion-log.service.spec.ts new file mode 100644 index 00000000000..21522e5e924 --- /dev/null +++ b/apps/server/src/modules/deletion/services/deletion-log.service.spec.ts @@ -0,0 +1,110 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { setupEntities } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { DeletionLogRepo } from '../repo'; +import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; +import { DeletionLogService } from './deletion-log.service'; +import { DeletionOperationModel } from '../domain/types/deletion-operation-model.enum'; +import { deletionLogFactory } from '../domain/testing/factory/deletion-log.factory'; + +describe(DeletionLogService.name, () => { + let module: TestingModule; + let service: DeletionLogService; + let deletionLogRepo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionLogService, + { + provide: DeletionLogRepo, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(DeletionLogService); + deletionLogRepo = module.get(DeletionLogRepo); + + await setupEntities(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('defined', () => { + it('should be defined', () => { + expect(service).toBeDefined(); + }); + }); + + describe('createDeletionRequest', () => { + describe('when creating a deletionRequest', () => { + const setup = () => { + const deletionRequestId = '653e4833cc39e5907a1e18d2'; + const domain = DeletionDomainModel.USER; + const operation = DeletionOperationModel.DELETE; + const modifiedCount = 0; + const deletedCount = 1; + + return { deletionRequestId, domain, operation, modifiedCount, deletedCount }; + }; + + it('should call deletionRequestRepo.create', async () => { + const { deletionRequestId, domain, operation, modifiedCount, deletedCount } = setup(); + + await service.createDeletionLog(deletionRequestId, domain, operation, modifiedCount, deletedCount); + + expect(deletionLogRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.any(String), + deletionRequestId, + domain, + operation, + modifiedCount, + deletedCount, + }) + ); + }); + }); + }); + + describe('findByDeletionRequestId', () => { + describe('when finding all logs for deletionRequestId', () => { + const setup = () => { + const deletionRequestId = new ObjectId().toHexString(); + const deletionLog1 = deletionLogFactory.build({ deletionRequestId }); + const deletionLog2 = deletionLogFactory.build({ + deletionRequestId, + domain: DeletionDomainModel.PSEUDONYMS, + }); + const deletionLogs = [deletionLog1, deletionLog2]; + + deletionLogRepo.findAllByDeletionRequestId.mockResolvedValue(deletionLogs); + + return { deletionRequestId, deletionLogs }; + }; + + it('should call deletionLogRepo.findAllByDeletionRequestId', async () => { + const { deletionRequestId } = setup(); + await service.findByDeletionRequestId(deletionRequestId); + + expect(deletionLogRepo.findAllByDeletionRequestId).toBeCalledWith(deletionRequestId); + }); + + it('should return array of two deletionLogs with deletionRequestId', async () => { + const { deletionRequestId, deletionLogs } = setup(); + const result = await service.findByDeletionRequestId(deletionRequestId); + + expect(result).toHaveLength(2); + expect(result).toEqual(deletionLogs); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/services/deletion-log.service.ts b/apps/server/src/modules/deletion/services/deletion-log.service.ts new file mode 100644 index 00000000000..937d422ebb3 --- /dev/null +++ b/apps/server/src/modules/deletion/services/deletion-log.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { DeletionLogRepo } from '../repo'; +import { DeletionLog } from '../domain/deletion-log.do'; +import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; +import { DeletionOperationModel } from '../domain/types/deletion-operation-model.enum'; + +@Injectable() +export class DeletionLogService { + constructor(private readonly deletionLogRepo: DeletionLogRepo) {} + + async createDeletionLog( + deletionRequestId: EntityId, + domain: DeletionDomainModel, + operation: DeletionOperationModel, + modifiedCount: number, + deletedCount: number + ): Promise { + const newDeletionLog = new DeletionLog({ + id: new ObjectId().toHexString(), + domain, + deletionRequestId, + operation, + modifiedCount, + deletedCount, + }); + + await this.deletionLogRepo.create(newDeletionLog); + } + + async findByDeletionRequestId(deletionRequestId: EntityId): Promise { + const deletionLogs: DeletionLog[] = await this.deletionLogRepo.findAllByDeletionRequestId(deletionRequestId); + + return deletionLogs; + } +} diff --git a/apps/server/src/modules/deletion/services/deletion-request.service.spec.ts b/apps/server/src/modules/deletion/services/deletion-request.service.spec.ts new file mode 100644 index 00000000000..fcccfc433db --- /dev/null +++ b/apps/server/src/modules/deletion/services/deletion-request.service.spec.ts @@ -0,0 +1,200 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { setupEntities } from '@shared/testing'; +import { DeletionRequestService } from './deletion-request.service'; +import { DeletionRequestRepo } from '../repo'; +import { deletionRequestFactory } from '../domain/testing/factory/deletion-request.factory'; +import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; +import { DeletionStatusModel } from '../domain/types/deletion-status-model.enum'; + +describe(DeletionRequestService.name, () => { + let module: TestingModule; + let service: DeletionRequestService; + let deletionRequestRepo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionRequestService, + { + provide: DeletionRequestRepo, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(DeletionRequestService); + deletionRequestRepo = module.get(DeletionRequestRepo); + + await setupEntities(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('defined', () => { + it('should be defined', () => { + expect(service).toBeDefined(); + }); + }); + + describe('createDeletionRequest', () => { + describe('when creating a deletionRequest', () => { + const setup = () => { + const targetRefId = '653e4833cc39e5907a1e18d2'; + const targetRefDomain = DeletionDomainModel.USER; + + return { targetRefId, targetRefDomain }; + }; + + it('should call deletionRequestRepo.create', async () => { + const { targetRefId, targetRefDomain } = setup(); + + await service.createDeletionRequest(targetRefId, targetRefDomain); + + expect(deletionRequestRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.any(String), + targetRefDomain, + deleteAfter: expect.any(Date), + targetRefId, + status: DeletionStatusModel.REGISTERED, + }) + ); + }); + }); + }); + + describe('findById', () => { + describe('when finding by deletionRequestId', () => { + const setup = () => { + const deletionRequestId = new ObjectId().toHexString(); + const deletionRequest = deletionRequestFactory.build({ id: deletionRequestId }); + + deletionRequestRepo.findById.mockResolvedValue(deletionRequest); + + return { deletionRequestId, deletionRequest }; + }; + + it('should call deletionRequestRepo.findById', async () => { + const { deletionRequestId } = setup(); + + await service.findById(deletionRequestId); + + expect(deletionRequestRepo.findById).toBeCalledWith(deletionRequestId); + }); + + it('should return deletionRequest', async () => { + const { deletionRequestId, deletionRequest } = setup(); + + const result = await service.findById(deletionRequestId); + + expect(result).toEqual(deletionRequest); + }); + }); + }); + + describe('findAllItemsToExecute', () => { + describe('when finding all deletionRequests for execution', () => { + const setup = () => { + const dateInPast = new Date(); + dateInPast.setDate(dateInPast.getDate() - 1); + const deletionRequest1 = deletionRequestFactory.build({ deleteAfter: dateInPast }); + const deletionRequest2 = deletionRequestFactory.build({ deleteAfter: dateInPast }); + + deletionRequestRepo.findAllItemsToExecution.mockResolvedValue([deletionRequest1, deletionRequest2]); + + const deletionRequests = [deletionRequest1, deletionRequest2]; + return { deletionRequests }; + }; + + it('should call deletionRequestRepo.findAllItemsByDeletionDate', async () => { + await service.findAllItemsToExecute(); + + expect(deletionRequestRepo.findAllItemsToExecution).toBeCalled(); + }); + + it('should return array of two deletionRequests to execute', async () => { + const { deletionRequests } = setup(); + const result = await service.findAllItemsToExecute(); + + expect(result).toHaveLength(2); + expect(result).toEqual(deletionRequests); + }); + }); + }); + + describe('update', () => { + describe('when updating deletionRequest', () => { + const setup = () => { + const deletionRequest = deletionRequestFactory.buildWithId(); + + return { deletionRequest }; + }; + + it('should call deletionRequestRepo.update', async () => { + const { deletionRequest } = setup(); + await service.update(deletionRequest); + + expect(deletionRequestRepo.update).toBeCalledWith(deletionRequest); + }); + }); + }); + + describe('markDeletionRequestAsExecuted', () => { + describe('when mark deletionRequest as executed', () => { + const setup = () => { + const deletionRequestId = new ObjectId().toHexString(); + + return { deletionRequestId }; + }; + + it('should call deletionRequestRepo.markDeletionRequestAsExecuted', async () => { + const { deletionRequestId } = setup(); + await service.markDeletionRequestAsExecuted(deletionRequestId); + + expect(deletionRequestRepo.markDeletionRequestAsExecuted).toBeCalledWith(deletionRequestId); + }); + }); + }); + + describe('markDeletionRequestAsFailed', () => { + describe('when mark deletionRequest as failed', () => { + const setup = () => { + const deletionRequestId = new ObjectId().toHexString(); + + return { deletionRequestId }; + }; + + it('should call deletionRequestRepo.markDeletionRequestAsExecuted', async () => { + const { deletionRequestId } = setup(); + await service.markDeletionRequestAsFailed(deletionRequestId); + + expect(deletionRequestRepo.markDeletionRequestAsFailed).toBeCalledWith(deletionRequestId); + }); + }); + }); + + describe('deleteById', () => { + describe('when deleting deletionRequest', () => { + const setup = () => { + const deletionRequestId = new ObjectId().toHexString(); + + return { deletionRequestId }; + }; + + it('should call deletionRequestRepo.findAllItemsByDeletionDate', async () => { + const { deletionRequestId } = setup(); + await service.deleteById(deletionRequestId); + + expect(deletionRequestRepo.deleteById).toBeCalledWith(deletionRequestId); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/services/deletion-request.service.ts b/apps/server/src/modules/deletion/services/deletion-request.service.ts new file mode 100644 index 00000000000..82b65521d68 --- /dev/null +++ b/apps/server/src/modules/deletion/services/deletion-request.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { DeletionRequestRepo } from '../repo/deletion-request.repo'; +import { DeletionRequest } from '../domain/deletion-request.do'; +import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; +import { DeletionStatusModel } from '../domain/types/deletion-status-model.enum'; + +@Injectable() +export class DeletionRequestService { + constructor(private readonly deletionRequestRepo: DeletionRequestRepo) {} + + async createDeletionRequest( + targetRefId: EntityId, + targetRefDomain: DeletionDomainModel, + deleteInMinutes = 43200 + ): Promise<{ requestId: EntityId; deletionPlannedAt: Date }> { + const dateOfDeletion = new Date(); + dateOfDeletion.setMinutes(dateOfDeletion.getMinutes() + deleteInMinutes); + + const newDeletionRequest = new DeletionRequest({ + id: new ObjectId().toHexString(), + targetRefDomain, + deleteAfter: dateOfDeletion, + targetRefId, + status: DeletionStatusModel.REGISTERED, + }); + + await this.deletionRequestRepo.create(newDeletionRequest); + + return { requestId: newDeletionRequest.id, deletionPlannedAt: newDeletionRequest.deleteAfter }; + } + + async findById(deletionRequestId: EntityId): Promise { + const deletionRequest: DeletionRequest = await this.deletionRequestRepo.findById(deletionRequestId); + + return deletionRequest; + } + + async findAllItemsToExecute(limit?: number): Promise { + const itemsToDelete: DeletionRequest[] = await this.deletionRequestRepo.findAllItemsToExecution(limit); + + return itemsToDelete; + } + + async update(deletionRequestToUpdate: DeletionRequest): Promise { + await this.deletionRequestRepo.update(deletionRequestToUpdate); + } + + async markDeletionRequestAsExecuted(deletionRequestId: EntityId): Promise { + return this.deletionRequestRepo.markDeletionRequestAsExecuted(deletionRequestId); + } + + async markDeletionRequestAsFailed(deletionRequestId: EntityId): Promise { + return this.deletionRequestRepo.markDeletionRequestAsFailed(deletionRequestId); + } + + async deleteById(deletionRequestId: EntityId): Promise { + await this.deletionRequestRepo.deleteById(deletionRequestId); + } +} diff --git a/apps/server/src/modules/deletion/services/index.ts b/apps/server/src/modules/deletion/services/index.ts new file mode 100644 index 00000000000..9661354718c --- /dev/null +++ b/apps/server/src/modules/deletion/services/index.ts @@ -0,0 +1 @@ +export * from './deletion-request.service'; diff --git a/apps/server/src/modules/deletion/uc/builder/deletion-log-statistic.builder.spec.ts b/apps/server/src/modules/deletion/uc/builder/deletion-log-statistic.builder.spec.ts new file mode 100644 index 00000000000..c2952f40f59 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/deletion-log-statistic.builder.spec.ts @@ -0,0 +1,22 @@ +import { DeletionDomainModel } from '../../domain/types/deletion-domain-model.enum'; +import { DeletionLogStatisticBuilder } from './deletion-log-statistic.builder'; + +describe(DeletionLogStatisticBuilder.name, () => { + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should build generic deletionLogStatistic with all attributes', () => { + // Arrange + const domain = DeletionDomainModel.PSEUDONYMS; + const modifiedCount = 0; + const deletedCount = 2; + + const result = DeletionLogStatisticBuilder.build(domain, modifiedCount, deletedCount); + + // Assert + expect(result.domain).toEqual(domain); + expect(result.modifiedCount).toEqual(modifiedCount); + expect(result.deletedCount).toEqual(deletedCount); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/builder/deletion-log-statistic.builder.ts b/apps/server/src/modules/deletion/uc/builder/deletion-log-statistic.builder.ts new file mode 100644 index 00000000000..a562505b885 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/deletion-log-statistic.builder.ts @@ -0,0 +1,10 @@ +import { DeletionDomainModel } from '../../domain/types/deletion-domain-model.enum'; +import { DeletionLogStatistic } from '../interface'; + +export class DeletionLogStatisticBuilder { + static build(domain: DeletionDomainModel, modifiedCount?: number, deletedCount?: number): DeletionLogStatistic { + const deletionLogStatistic = { domain, modifiedCount, deletedCount }; + + return deletionLogStatistic; + } +} diff --git a/apps/server/src/modules/deletion/uc/builder/deletion-request-log.builder.spec.ts b/apps/server/src/modules/deletion/uc/builder/deletion-request-log.builder.spec.ts new file mode 100644 index 00000000000..b317a4b2221 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/deletion-request-log.builder.spec.ts @@ -0,0 +1,28 @@ +import { DeletionDomainModel } from '../../domain/types/deletion-domain-model.enum'; +import { DeletionLogStatisticBuilder } from './deletion-log-statistic.builder'; +import { DeletionRequestLogBuilder } from './deletion-request-log.builder'; +import { DeletionTargetRefBuilder } from './deletion-target-ref.builder'; + +describe(DeletionRequestLogBuilder.name, () => { + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should build generic deletionRequestLog with all attributes', () => { + // Arrange + const targetRefDomain = DeletionDomainModel.PSEUDONYMS; + const targetRefId = '653e4833cc39e5907a1e18d2'; + const targetRef = DeletionTargetRefBuilder.build(targetRefDomain, targetRefId); + const deletionPlannedAt = new Date(); + const modifiedCount = 0; + const deletedCount = 2; + const statistics = [DeletionLogStatisticBuilder.build(targetRefDomain, modifiedCount, deletedCount)]; + + const result = DeletionRequestLogBuilder.build(targetRef, deletionPlannedAt, statistics); + + // Assert + expect(result.targetRef).toEqual(targetRef); + expect(result.deletionPlannedAt).toEqual(deletionPlannedAt); + expect(result.statistics).toEqual(statistics); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/builder/deletion-request-log.builder.ts b/apps/server/src/modules/deletion/uc/builder/deletion-request-log.builder.ts new file mode 100644 index 00000000000..8247acf6776 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/deletion-request-log.builder.ts @@ -0,0 +1,13 @@ +import { DeletionLogStatistic, DeletionRequestLog, DeletionTargetRef } from '../interface'; + +export class DeletionRequestLogBuilder { + static build( + targetRef: DeletionTargetRef, + deletionPlannedAt: Date, + statistics?: DeletionLogStatistic[] + ): DeletionRequestLog { + const deletionRequestLog = { targetRef, deletionPlannedAt, statistics }; + + return deletionRequestLog; + } +} diff --git a/apps/server/src/modules/deletion/uc/builder/deletion-target-ref.builder.spec.ts b/apps/server/src/modules/deletion/uc/builder/deletion-target-ref.builder.spec.ts new file mode 100644 index 00000000000..2fb4ae440a7 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/deletion-target-ref.builder.spec.ts @@ -0,0 +1,20 @@ +import { DeletionDomainModel } from '../../domain/types/deletion-domain-model.enum'; +import { DeletionTargetRefBuilder } from './deletion-target-ref.builder'; + +describe(DeletionTargetRefBuilder.name, () => { + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should build generic deletionTargetRef with all attributes', () => { + // Arrange + const domain = DeletionDomainModel.PSEUDONYMS; + const refId = '653e4833cc39e5907a1e18d2'; + + const result = DeletionTargetRefBuilder.build(domain, refId); + + // Assert + expect(result.targetRefDomain).toEqual(domain); + expect(result.targetRefId).toEqual(refId); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/builder/deletion-target-ref.builder.ts b/apps/server/src/modules/deletion/uc/builder/deletion-target-ref.builder.ts new file mode 100644 index 00000000000..91f3385a9aa --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/deletion-target-ref.builder.ts @@ -0,0 +1,11 @@ +import { EntityId } from '@shared/domain'; +import { DeletionDomainModel } from '../../domain/types/deletion-domain-model.enum'; +import { DeletionTargetRef } from '../interface'; + +export class DeletionTargetRefBuilder { + static build(targetRefDomain: DeletionDomainModel, targetRefId: EntityId): DeletionTargetRef { + const deletionTargetRef = { targetRefDomain, targetRefId }; + + return deletionTargetRef; + } +} diff --git a/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts b/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts new file mode 100644 index 00000000000..063f3d46b48 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts @@ -0,0 +1,459 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { setupEntities } from '@shared/testing'; +import { AccountService } from '@modules/account/services'; +import { ClassService } from '@modules/class'; +import { CourseGroupService, CourseService } from '@modules/learnroom/service'; +import { FilesService } from '@modules/files/service'; +import { LessonService } from '@modules/lesson/service'; +import { PseudonymService } from '@modules/pseudonym'; +import { TeamService } from '@modules/teams'; +import { UserService } from '@modules/user'; +import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; +import { DeletionLogService } from '../services/deletion-log.service'; +import { DeletionRequestService } from '../services'; +import { DeletionRequestUc } from './deletion-request.uc'; +import { DeletionRequestLog, DeletionRequestProps } from './interface/interfaces'; +import { deletionRequestFactory } from '../domain/testing/factory/deletion-request.factory'; +import { DeletionStatusModel } from '../domain/types/deletion-status-model.enum'; +import { deletionLogFactory } from '../domain/testing/factory/deletion-log.factory'; + +describe(DeletionRequestUc.name, () => { + let module: TestingModule; + let uc: DeletionRequestUc; + let deletionRequestService: DeepMocked; + let deletionLogService: DeepMocked; + let accountService: DeepMocked; + let classService: DeepMocked; + let courseGroupService: DeepMocked; + let courseService: DeepMocked; + let filesService: DeepMocked; + let lessonService: DeepMocked; + let pseudonymService: DeepMocked; + let teamService: DeepMocked; + let userService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionRequestUc, + { + provide: DeletionRequestService, + useValue: createMock(), + }, + { + provide: DeletionLogService, + useValue: createMock(), + }, + { + provide: AccountService, + useValue: createMock(), + }, + { + provide: ClassService, + useValue: createMock(), + }, + { + provide: CourseGroupService, + useValue: createMock(), + }, + { + provide: CourseService, + useValue: createMock(), + }, + { + provide: FilesService, + useValue: createMock(), + }, + { + provide: LessonService, + useValue: createMock(), + }, + { + provide: PseudonymService, + useValue: createMock(), + }, + { + provide: TeamService, + useValue: createMock(), + }, + { + provide: UserService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(DeletionRequestUc); + deletionRequestService = module.get(DeletionRequestService); + deletionLogService = module.get(DeletionLogService); + accountService = module.get(AccountService); + classService = module.get(ClassService); + courseGroupService = module.get(CourseGroupService); + courseService = module.get(CourseService); + filesService = module.get(FilesService); + lessonService = module.get(LessonService); + pseudonymService = module.get(PseudonymService); + teamService = module.get(TeamService); + userService = module.get(UserService); + await setupEntities(); + }); + + describe('createDeletionRequest', () => { + describe('when creating a deletionRequest', () => { + const setup = () => { + jest.clearAllMocks(); + const deletionRequestToCreate: DeletionRequestProps = { + targetRef: { + targetRefDoamin: DeletionDomainModel.USER, + targetRefId: '653e4833cc39e5907a1e18d2', + }, + deleteInMinutes: 1440, + }; + const deletionRequest = deletionRequestFactory.build(); + + return { + deletionRequestToCreate, + deletionRequest, + }; + }; + + it('should call the service to create the deletionRequest', async () => { + const { deletionRequestToCreate } = setup(); + + await uc.createDeletionRequest(deletionRequestToCreate); + + expect(deletionRequestService.createDeletionRequest).toHaveBeenCalledWith( + deletionRequestToCreate.targetRef.targetRefId, + deletionRequestToCreate.targetRef.targetRefDoamin, + deletionRequestToCreate.deleteInMinutes + ); + }); + + it('should return the deletionRequestID and deletionPlannedAt', async () => { + const { deletionRequestToCreate, deletionRequest } = setup(); + + deletionRequestService.createDeletionRequest.mockResolvedValueOnce({ + requestId: deletionRequest.id, + deletionPlannedAt: deletionRequest.deleteAfter, + }); + + const result = await uc.createDeletionRequest(deletionRequestToCreate); + + expect(result).toEqual({ + requestId: deletionRequest.id, + deletionPlannedAt: deletionRequest.deleteAfter, + }); + }); + }); + }); + + describe('executeDeletionRequests', () => { + describe('when executing deletionRequests', () => { + const setup = () => { + jest.clearAllMocks(); + const deletionRequestToExecute = deletionRequestFactory.build({ deleteAfter: new Date('2023-01-01') }); + + classService.deleteUserDataFromClasses.mockResolvedValueOnce(1); + courseGroupService.deleteUserDataFromCourseGroup.mockResolvedValueOnce(2); + courseService.deleteUserDataFromCourse.mockResolvedValueOnce(2); + filesService.markFilesOwnedByUserForDeletion.mockResolvedValueOnce(2); + filesService.removeUserPermissionsToAnyFiles.mockResolvedValueOnce(2); + lessonService.deleteUserDataFromLessons.mockResolvedValueOnce(2); + pseudonymService.deleteByUserId.mockResolvedValueOnce(2); + teamService.deleteUserDataFromTeams.mockResolvedValueOnce(2); + userService.deleteUser.mockResolvedValueOnce(1); + + return { + deletionRequestToExecute, + }; + }; + + it('should call deletionRequestService.findAllItemsToExecute', async () => { + await uc.executeDeletionRequests(); + + expect(deletionRequestService.findAllItemsToExecute).toHaveBeenCalled(); + }); + + it('should call deletionRequestService.markDeletionRequestAsExecuted to update status of deletionRequests', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(deletionRequestService.markDeletionRequestAsExecuted).toHaveBeenCalledWith(deletionRequestToExecute.id); + }); + + it('should call accountService.deleteByUserId to delete user data in account module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(accountService.deleteByUserId).toHaveBeenCalled(); + }); + + it('should call classService.deleteUserDataFromClasses to delete user data in class module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(classService.deleteUserDataFromClasses).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call courseGroupService.deleteUserDataFromCourseGroup to delete user data in courseGroup module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(courseGroupService.deleteUserDataFromCourseGroup).toHaveBeenCalledWith( + deletionRequestToExecute.targetRefId + ); + }); + + it('should call courseService.deleteUserDataFromCourse to delete user data in course module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(courseService.deleteUserDataFromCourse).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call filesService.markFilesOwnedByUserForDeletion to mark users files to delete in file module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(filesService.markFilesOwnedByUserForDeletion).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call filesService.removeUserPermissionsToAnyFiles to remove users permissions to any files in file module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(filesService.removeUserPermissionsToAnyFiles).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call lessonService.deleteUserDataFromLessons to delete users data in lesson module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(lessonService.deleteUserDataFromLessons).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call pseudonymService.deleteByUserId to delete users data in pseudonym module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(pseudonymService.deleteByUserId).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call teamService.deleteUserDataFromTeams to delete users data in teams module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(teamService.deleteUserDataFromTeams).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call userService.deleteUsers to delete user in user module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(userService.deleteUser).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call deletionLogService.createDeletionLog to create logs for deletionRequest', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(deletionLogService.createDeletionLog).toHaveBeenCalledTimes(9); + }); + }); + + describe('when an error occurred', () => { + const setup = () => { + jest.clearAllMocks(); + const deletionRequestToExecute = deletionRequestFactory.build({ deleteAfter: new Date('2023-01-01') }); + + classService.deleteUserDataFromClasses.mockResolvedValueOnce(1); + courseGroupService.deleteUserDataFromCourseGroup.mockResolvedValueOnce(2); + courseService.deleteUserDataFromCourse.mockResolvedValueOnce(2); + filesService.markFilesOwnedByUserForDeletion.mockResolvedValueOnce(2); + filesService.removeUserPermissionsToAnyFiles.mockResolvedValueOnce(2); + lessonService.deleteUserDataFromLessons.mockResolvedValueOnce(2); + pseudonymService.deleteByUserId.mockResolvedValueOnce(2); + teamService.deleteUserDataFromTeams.mockResolvedValueOnce(2); + userService.deleteUser.mockRejectedValueOnce(new Error()); + + return { + deletionRequestToExecute, + }; + }; + + it('should throw an arror', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(deletionRequestService.markDeletionRequestAsFailed).toHaveBeenCalledWith(deletionRequestToExecute.id); + }); + }); + }); + + describe('findById', () => { + describe('when searching for logs for deletionRequest which was executed', () => { + const setup = () => { + jest.clearAllMocks(); + const deletionRequestExecuted = deletionRequestFactory.build({ status: DeletionStatusModel.SUCCESS }); + const deletionLogExecuted1 = deletionLogFactory.build({ deletionRequestId: deletionRequestExecuted.id }); + const deletionLogExecuted2 = deletionLogFactory.build({ + deletionRequestId: deletionRequestExecuted.id, + domain: DeletionDomainModel.ACCOUNT, + modifiedCount: 0, + deletedCount: 1, + }); + + const executedDeletionRequestSummary: DeletionRequestLog = { + targetRef: { + targetRefDomain: deletionRequestExecuted.targetRefDomain, + targetRefId: deletionRequestExecuted.targetRefId, + }, + deletionPlannedAt: deletionRequestExecuted.deleteAfter, + statistics: [ + { + domain: deletionLogExecuted1.domain, + modifiedCount: deletionLogExecuted1.modifiedCount, + deletedCount: deletionLogExecuted1.deletedCount, + }, + { + domain: deletionLogExecuted2.domain, + modifiedCount: deletionLogExecuted2.modifiedCount, + deletedCount: deletionLogExecuted2.deletedCount, + }, + ], + }; + + return { + deletionRequestExecuted, + executedDeletionRequestSummary, + deletionLogExecuted1, + deletionLogExecuted2, + }; + }; + + it('should call to deletionRequestService and deletionLogService', async () => { + const { deletionRequestExecuted } = setup(); + + deletionRequestService.findById.mockResolvedValueOnce(deletionRequestExecuted); + + await uc.findById(deletionRequestExecuted.id); + + expect(deletionRequestService.findById).toHaveBeenCalledWith(deletionRequestExecuted.id); + expect(deletionLogService.findByDeletionRequestId).toHaveBeenCalledWith(deletionRequestExecuted.id); + }); + + it('should return object with summary of deletionRequest', async () => { + const { deletionRequestExecuted, deletionLogExecuted1, deletionLogExecuted2, executedDeletionRequestSummary } = + setup(); + + deletionRequestService.findById.mockResolvedValueOnce(deletionRequestExecuted); + deletionLogService.findByDeletionRequestId.mockResolvedValueOnce([deletionLogExecuted1, deletionLogExecuted2]); + + const result = await uc.findById(deletionRequestExecuted.id); + + expect(result).toEqual(executedDeletionRequestSummary); + }); + }); + + describe('when searching for logs for deletionRequest which was not executed', () => { + const setup = () => { + jest.clearAllMocks(); + const deletionRequest = deletionRequestFactory.build(); + const notExecutedDeletionRequestSummary: DeletionRequestLog = { + targetRef: { + targetRefDomain: deletionRequest.targetRefDomain, + targetRefId: deletionRequest.targetRefId, + }, + deletionPlannedAt: deletionRequest.deleteAfter, + }; + + return { + deletionRequest, + notExecutedDeletionRequestSummary, + }; + }; + + it('should call to deletionRequestService', async () => { + const { deletionRequest } = setup(); + + deletionRequestService.findById.mockResolvedValueOnce(deletionRequest); + + await uc.findById(deletionRequest.id); + + expect(deletionRequestService.findById).toHaveBeenCalledWith(deletionRequest.id); + expect(deletionLogService.findByDeletionRequestId).not.toHaveBeenCalled(); + }); + + it('should return object with summary of deletionRequest', async () => { + const { deletionRequest, notExecutedDeletionRequestSummary } = setup(); + + deletionRequestService.findById.mockResolvedValueOnce(deletionRequest); + + const result = await uc.findById(deletionRequest.id); + + expect(result).toEqual(notExecutedDeletionRequestSummary); + }); + }); + }); + + describe('deleteDeletionRequestById', () => { + describe('when deleting a deletionRequestId', () => { + const setup = () => { + jest.clearAllMocks(); + const deletionRequest = deletionRequestFactory.build(); + + return { + deletionRequest, + }; + }; + + it('should call the service deletionRequestService.deleteById', async () => { + const { deletionRequest } = setup(); + + await uc.deleteDeletionRequestById(deletionRequest.id); + + expect(deletionRequestService.deleteById).toHaveBeenCalledWith(deletionRequest.id); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/deletion-request.uc.ts b/apps/server/src/modules/deletion/uc/deletion-request.uc.ts new file mode 100644 index 00000000000..d94a129310f --- /dev/null +++ b/apps/server/src/modules/deletion/uc/deletion-request.uc.ts @@ -0,0 +1,209 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { PseudonymService } from '@modules/pseudonym'; +import { UserService } from '@modules/user'; +import { TeamService } from '@modules/teams'; +import { ClassService } from '@modules/class'; +import { LessonService } from '@modules/lesson/service'; +import { CourseGroupService, CourseService } from '@modules/learnroom/service'; +import { FilesService } from '@modules/files/service'; +import { AccountService } from '@modules/account/services'; +import { DeletionRequestService } from '../services/deletion-request.service'; +import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; +import { DeletionLogService } from '../services/deletion-log.service'; +import { DeletionRequest } from '../domain/deletion-request.do'; +import { DeletionOperationModel } from '../domain/types/deletion-operation-model.enum'; +import { DeletionStatusModel } from '../domain/types/deletion-status-model.enum'; +import { DeletionLog } from '../domain/deletion-log.do'; +import { + DeletionRequestProps, + DeletionRequestLog, + DeletionLogStatistic, + DeletionRequestCreateAnswer, +} from './interface/interfaces'; +import { DeletionLogStatisticBuilder } from './builder/deletion-log-statistic.builder'; +import { DeletionRequestLogBuilder } from './builder/deletion-request-log.builder'; +import { DeletionTargetRefBuilder } from './builder/deletion-target-ref.builder'; + +@Injectable() +export class DeletionRequestUc { + constructor( + private readonly deletionRequestService: DeletionRequestService, + private readonly deletionLogService: DeletionLogService, + private readonly accountService: AccountService, + private readonly classService: ClassService, + private readonly courseGroupService: CourseGroupService, + private readonly courseService: CourseService, + private readonly filesService: FilesService, + private readonly lessonService: LessonService, + private readonly pseudonymService: PseudonymService, + private readonly teamService: TeamService, + private readonly userService: UserService + ) {} + + async createDeletionRequest(deletionRequest: DeletionRequestProps): Promise { + const result = await this.deletionRequestService.createDeletionRequest( + deletionRequest.targetRef.targetRefId, + deletionRequest.targetRef.targetRefDoamin, + deletionRequest.deleteInMinutes + ); + + return result; + } + + async executeDeletionRequests(limit?: number): Promise { + const deletionRequestToExecution: DeletionRequest[] = await this.deletionRequestService.findAllItemsToExecute( + limit + ); + + for (const req of deletionRequestToExecution) { + // eslint-disable-next-line no-await-in-loop + await this.executeDeletionRequest(req); + } + } + + async findById(deletionRequestId: EntityId): Promise { + const deletionRequest: DeletionRequest = await this.deletionRequestService.findById(deletionRequestId); + let response: DeletionRequestLog = DeletionRequestLogBuilder.build( + DeletionTargetRefBuilder.build(deletionRequest.targetRefDomain, deletionRequest.targetRefId), + deletionRequest.deleteAfter + ); + + if (deletionRequest.status === DeletionStatusModel.SUCCESS) { + const deletionLog: DeletionLog[] = await this.deletionLogService.findByDeletionRequestId(deletionRequestId); + const deletionLogStatistic: DeletionLogStatistic[] = deletionLog.map((log) => + DeletionLogStatisticBuilder.build(log.domain, log.modifiedCount, log.deletedCount) + ); + response = { ...response, statistics: deletionLogStatistic }; + } + + return response; + } + + async deleteDeletionRequestById(deletionRequestId: EntityId): Promise { + await this.deletionRequestService.deleteById(deletionRequestId); + } + + private async executeDeletionRequest(deletionRequest: DeletionRequest): Promise { + try { + await Promise.all([ + this.removeAccount(deletionRequest), + this.removeUserFromClasses(deletionRequest), + this.removeUserFromCourseGroup(deletionRequest), + this.removeUserFromCourse(deletionRequest), + this.removeUsersFilesAndPermissions(deletionRequest), + this.removeUserFromLessons(deletionRequest), + this.removeUsersPseudonyms(deletionRequest), + this.removeUserFromTeams(deletionRequest), + this.removeUser(deletionRequest), + ]); + await this.deletionRequestService.markDeletionRequestAsExecuted(deletionRequest.id); + } catch (error) { + await this.deletionRequestService.markDeletionRequestAsFailed(deletionRequest.id); + } + } + + private async logDeletion( + deletionRequest: DeletionRequest, + domainModel: DeletionDomainModel, + operationModel: DeletionOperationModel, + updatedCount: number, + deletedCount: number + ): Promise { + if (updatedCount > 0 || deletedCount > 0) { + await this.deletionLogService.createDeletionLog( + deletionRequest.id, + domainModel, + operationModel, + updatedCount, + deletedCount + ); + } + } + + private async removeAccount(deletionRequest: DeletionRequest) { + await this.accountService.deleteByUserId(deletionRequest.targetRefId); + await this.logDeletion(deletionRequest, DeletionDomainModel.ACCOUNT, DeletionOperationModel.DELETE, 0, 1); + } + + private async removeUserFromClasses(deletionRequest: DeletionRequest) { + const classesUpdated: number = await this.classService.deleteUserDataFromClasses(deletionRequest.targetRefId); + await this.logDeletion( + deletionRequest, + DeletionDomainModel.CLASS, + DeletionOperationModel.UPDATE, + classesUpdated, + 0 + ); + } + + private async removeUserFromCourseGroup(deletionRequest: DeletionRequest) { + const courseGroupUpdated: number = await this.courseGroupService.deleteUserDataFromCourseGroup( + deletionRequest.targetRefId + ); + await this.logDeletion( + deletionRequest, + DeletionDomainModel.COURSEGROUP, + DeletionOperationModel.UPDATE, + courseGroupUpdated, + 0 + ); + } + + private async removeUserFromCourse(deletionRequest: DeletionRequest) { + const courseUpdated: number = await this.courseService.deleteUserDataFromCourse(deletionRequest.targetRefId); + await this.logDeletion( + deletionRequest, + DeletionDomainModel.COURSE, + DeletionOperationModel.UPDATE, + courseUpdated, + 0 + ); + } + + private async removeUsersFilesAndPermissions(deletionRequest: DeletionRequest) { + const filesDeleted: number = await this.filesService.markFilesOwnedByUserForDeletion(deletionRequest.targetRefId); + const filePermissionsUpdated: number = await this.filesService.removeUserPermissionsToAnyFiles( + deletionRequest.targetRefId + ); + await this.logDeletion( + deletionRequest, + DeletionDomainModel.FILE, + DeletionOperationModel.UPDATE, + filesDeleted + filePermissionsUpdated, + 0 + ); + } + + private async removeUserFromLessons(deletionRequest: DeletionRequest) { + const lessonsUpdated: number = await this.lessonService.deleteUserDataFromLessons(deletionRequest.targetRefId); + await this.logDeletion( + deletionRequest, + DeletionDomainModel.LESSONS, + DeletionOperationModel.UPDATE, + lessonsUpdated, + 0 + ); + } + + private async removeUsersPseudonyms(deletionRequest: DeletionRequest) { + const pseudonymDeleted: number = await this.pseudonymService.deleteByUserId(deletionRequest.targetRefId); + await this.logDeletion( + deletionRequest, + DeletionDomainModel.PSEUDONYMS, + DeletionOperationModel.DELETE, + 0, + pseudonymDeleted + ); + } + + private async removeUserFromTeams(deletionRequest: DeletionRequest) { + const teamsUpdated: number = await this.teamService.deleteUserDataFromTeams(deletionRequest.targetRefId); + await this.logDeletion(deletionRequest, DeletionDomainModel.TEAMS, DeletionOperationModel.UPDATE, teamsUpdated, 0); + } + + private async removeUser(deletionRequest: DeletionRequest) { + const userDeleted: number = await this.userService.deleteUser(deletionRequest.targetRefId); + await this.logDeletion(deletionRequest, DeletionDomainModel.USER, DeletionOperationModel.DELETE, 0, userDeleted); + } +} diff --git a/apps/server/src/modules/deletion/uc/interface/index.ts b/apps/server/src/modules/deletion/uc/interface/index.ts new file mode 100644 index 00000000000..95786098275 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/interface/index.ts @@ -0,0 +1 @@ +export * from './interfaces'; diff --git a/apps/server/src/modules/deletion/uc/interface/interfaces.ts b/apps/server/src/modules/deletion/uc/interface/interfaces.ts new file mode 100644 index 00000000000..47f4d887735 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/interface/interfaces.ts @@ -0,0 +1,29 @@ +import { EntityId } from '@shared/domain'; +import { DeletionDomainModel } from '../../domain/types/deletion-domain-model.enum'; + +export interface DeletionTargetRef { + targetRefDomain: DeletionDomainModel; + targetRefId: EntityId; +} + +export interface DeletionRequestLog { + targetRef: DeletionTargetRef; + deletionPlannedAt: Date; + statistics?: DeletionLogStatistic[]; +} + +export interface DeletionLogStatistic { + domain: DeletionDomainModel; + modifiedCount?: number; + deletedCount?: number; +} + +export interface DeletionRequestProps { + targetRef: { targetRefDoamin: DeletionDomainModel; targetRefId: EntityId }; + deleteInMinutes?: number; +} + +export interface DeletionRequestCreateAnswer { + requestId: EntityId; + deletionPlannedAt: Date; +} diff --git a/apps/server/src/modules/learnroom/service/index.ts b/apps/server/src/modules/learnroom/service/index.ts index 608249cbf43..ca9d75634cf 100644 --- a/apps/server/src/modules/learnroom/service/index.ts +++ b/apps/server/src/modules/learnroom/service/index.ts @@ -4,3 +4,4 @@ export * from './column-board-target.service'; export * from './common-cartridge-export.service'; export * from './course.service'; export * from './rooms.service'; +export * from './coursegroup.service';