diff --git a/apps/server/src/apps/deletion-console.app.ts b/apps/server/src/apps/deletion-console.app.ts new file mode 100644 index 00000000000..cafb137e160 --- /dev/null +++ b/apps/server/src/apps/deletion-console.app.ts @@ -0,0 +1,31 @@ +/* istanbul ignore file */ +import { BootstrapConsole } from 'nestjs-console'; +import { DeletionConsoleModule } from '@modules/deletion'; + +async function run() { + const bootstrap = new BootstrapConsole({ + module: DeletionConsoleModule, + useDecorators: true, + }); + + const app = await bootstrap.init(); + + try { + await app.init(); + + // Execute console application with provided arguments. + await bootstrap.boot(); + } catch (err) { + // eslint-disable-next-line no-console, @typescript-eslint/no-unsafe-call + console.error(err); + + // Set the exit code to 1 to indicate a console app failure. + process.exitCode = 1; + } + + // Always close the app, even if some exception + // has been thrown from the console app. + await app.close(); +} + +void run(); diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.spec.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.spec.ts new file mode 100644 index 00000000000..bd49bb841e6 --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.spec.ts @@ -0,0 +1,33 @@ +import { ObjectId } from 'bson'; +import { DeletionRequestInput } from '../interface'; +import { DeletionRequestInputBuilder } from './deletion-request-input.builder'; + +describe(DeletionRequestInputBuilder.name, () => { + describe(DeletionRequestInputBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const targetRefDomain = 'school'; + const targetRefId = new ObjectId().toHexString(); + const deleteInMinutes = 43200; + + const expectedOutput: DeletionRequestInput = { + targetRef: { + domain: targetRefDomain, + id: targetRefId, + }, + deleteInMinutes, + }; + + return { targetRefDomain, targetRefId, deleteInMinutes, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { targetRefDomain, targetRefId, deleteInMinutes, expectedOutput } = setup(); + + const output = DeletionRequestInputBuilder.build(targetRefDomain, targetRefId, deleteInMinutes); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.ts new file mode 100644 index 00000000000..28091418065 --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.ts @@ -0,0 +1,11 @@ +import { DeletionRequestInput } from '../interface'; +import { DeletionRequestTargetRefInputBuilder } from './deletion-request-target-ref-input.builder'; + +export class DeletionRequestInputBuilder { + static build(targetRefDomain: string, targetRefId: string, deleteInMinutes?: number): DeletionRequestInput { + return { + targetRef: DeletionRequestTargetRefInputBuilder.build(targetRefDomain, targetRefId), + deleteInMinutes, + }; + } +} diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.spec.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.spec.ts new file mode 100644 index 00000000000..399821f33ff --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.spec.ts @@ -0,0 +1,29 @@ +import { ObjectId } from 'bson'; +import { DeletionRequestOutput } from '../interface'; +import { DeletionRequestOutputBuilder } from './deletion-request-output.builder'; + +describe(DeletionRequestOutputBuilder.name, () => { + describe(DeletionRequestOutputBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const requestId = new ObjectId().toHexString(); + const deletionPlannedAt = new Date(); + + const expectedOutput: DeletionRequestOutput = { + requestId, + deletionPlannedAt, + }; + + return { requestId, deletionPlannedAt, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { requestId, deletionPlannedAt, expectedOutput } = setup(); + + const output = DeletionRequestOutputBuilder.build(requestId, deletionPlannedAt); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.ts new file mode 100644 index 00000000000..9192c1a47ce --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.ts @@ -0,0 +1,10 @@ +import { DeletionRequestOutput } from '../interface'; + +export class DeletionRequestOutputBuilder { + static build(requestId: string, deletionPlannedAt: Date): DeletionRequestOutput { + return { + requestId, + deletionPlannedAt, + }; + } +} diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.spec.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.spec.ts new file mode 100644 index 00000000000..74b0631e49d --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.spec.ts @@ -0,0 +1,26 @@ +import { ObjectId } from 'bson'; +import { DeletionRequestTargetRefInput } from '../interface'; +import { DeletionRequestTargetRefInputBuilder } from './deletion-request-target-ref-input.builder'; + +describe(DeletionRequestTargetRefInputBuilder.name, () => { + describe(DeletionRequestTargetRefInputBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const domain = 'user'; + const id = new ObjectId().toHexString(); + + const expectedOutput: DeletionRequestTargetRefInput = { domain, id }; + + return { domain, id, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { domain, id, expectedOutput } = setup(); + + const output = DeletionRequestTargetRefInputBuilder.build(domain, id); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.ts new file mode 100644 index 00000000000..ed3c0219993 --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.ts @@ -0,0 +1,7 @@ +import { DeletionRequestTargetRefInput } from '../interface'; + +export class DeletionRequestTargetRefInputBuilder { + static build(domain: string, id: string): DeletionRequestTargetRefInput { + return { domain, id }; + } +} diff --git a/apps/server/src/modules/deletion/client/builder/index.ts b/apps/server/src/modules/deletion/client/builder/index.ts new file mode 100644 index 00000000000..85644a6b2ee --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/index.ts @@ -0,0 +1,3 @@ +export * from './deletion-request-target-ref-input.builder'; +export * from './deletion-request-input.builder'; +export * from './deletion-request-output.builder'; diff --git a/apps/server/src/modules/deletion/client/deletion-client.config.spec.ts b/apps/server/src/modules/deletion/client/deletion-client.config.spec.ts new file mode 100644 index 00000000000..a3cae21e425 --- /dev/null +++ b/apps/server/src/modules/deletion/client/deletion-client.config.spec.ts @@ -0,0 +1,41 @@ +import { IConfig } from '@hpi-schul-cloud/commons/lib/interfaces/IConfig'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { DeletionClientConfig } from './interface'; +import { getDeletionClientConfig } from './deletion-client.config'; + +describe(getDeletionClientConfig.name, () => { + let configBefore: IConfig; + + beforeAll(() => { + configBefore = Configuration.toObject({ plainSecrets: true }); + }); + + afterEach(() => { + Configuration.reset(configBefore); + }); + + describe('when called', () => { + const setup = () => { + const baseUrl = 'http://api-admin:4030'; + const apiKey = '652559c2-93da-42ad-94e1-640e3afbaca0'; + + Configuration.set('ADMIN_API_CLIENT__BASE_URL', baseUrl); + Configuration.set('ADMIN_API_CLIENT__API_KEY', apiKey); + + const expectedConfig: DeletionClientConfig = { + ADMIN_API_CLIENT_BASE_URL: baseUrl, + ADMIN_API_CLIENT_API_KEY: apiKey, + }; + + return { expectedConfig }; + }; + + it('should return config with proper values', () => { + const { expectedConfig } = setup(); + + const config = getDeletionClientConfig(); + + expect(config).toEqual(expectedConfig); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/client/deletion-client.config.ts b/apps/server/src/modules/deletion/client/deletion-client.config.ts new file mode 100644 index 00000000000..db5bf7ff226 --- /dev/null +++ b/apps/server/src/modules/deletion/client/deletion-client.config.ts @@ -0,0 +1,9 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { DeletionClientConfig } from './interface'; + +export const getDeletionClientConfig = (): DeletionClientConfig => { + return { + ADMIN_API_CLIENT_BASE_URL: Configuration.get('ADMIN_API_CLIENT__BASE_URL') as string, + ADMIN_API_CLIENT_API_KEY: Configuration.get('ADMIN_API_CLIENT__API_KEY') as string, + }; +}; diff --git a/apps/server/src/modules/deletion/client/deletion.client.spec.ts b/apps/server/src/modules/deletion/client/deletion.client.spec.ts new file mode 100644 index 00000000000..096b1f9b082 --- /dev/null +++ b/apps/server/src/modules/deletion/client/deletion.client.spec.ts @@ -0,0 +1,154 @@ +import { of } from 'rxjs'; +import { AxiosResponse } from 'axios'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { axiosResponseFactory } from '@shared/testing'; +import { DeletionRequestInputBuilder, DeletionRequestOutputBuilder } from '.'; +import { DeletionRequestOutput } from './interface'; +import { DeletionClient } from './deletion.client'; + +describe(DeletionClient.name, () => { + let module: TestingModule; + let client: DeletionClient; + let httpService: DeepMocked; + + beforeEach(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionClient, + { + provide: ConfigService, + useValue: createMock({ + get: jest.fn((key: string) => { + if (key === 'ADMIN_API_CLIENT_BASE_URL') { + return 'http://localhost:4030'; + } + + // Default is for the Admin APIs API Key. + return '6b3df003-61e9-467c-9e6b-579634801896'; + }), + }), + }, + { + provide: HttpService, + useValue: createMock(), + }, + ], + }).compile(); + + client = module.get(DeletionClient); + httpService = module.get(HttpService); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('queueDeletionRequest', () => { + describe('when received valid response with expected HTTP status code', () => { + const setup = () => { + const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); + + const output: DeletionRequestOutput = DeletionRequestOutputBuilder.build( + '6536ce29b595d7c8e5faf200', + new Date('2024-10-15T12:42:50.521Z') + ); + + const response: AxiosResponse = axiosResponseFactory.build({ + data: output, + status: 202, + }); + + httpService.post.mockReturnValueOnce(of(response)); + + return { input, output }; + }; + + it('should return proper output', async () => { + const { input, output } = setup(); + + const result = await client.queueDeletionRequest(input); + + expect(result).toEqual(output); + }); + }); + + describe('when received invalid HTTP status code in a response', () => { + const setup = () => { + const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); + + const output: DeletionRequestOutput = DeletionRequestOutputBuilder.build('', new Date()); + + const response: AxiosResponse = axiosResponseFactory.build({ + data: output, + status: 200, + }); + + httpService.post.mockReturnValueOnce(of(response)); + + return { input }; + }; + + it('should throw an exception', async () => { + const { input } = setup(); + + await expect(client.queueDeletionRequest(input)).rejects.toThrow(Error); + }); + }); + + describe('when received no requestId in a response', () => { + const setup = () => { + const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); + + const output: DeletionRequestOutput = DeletionRequestOutputBuilder.build( + '', + new Date('2024-10-15T12:42:50.521Z') + ); + + const response: AxiosResponse = axiosResponseFactory.build({ + data: output, + status: 202, + }); + + httpService.post.mockReturnValueOnce(of(response)); + + return { input }; + }; + + it('should throw an exception', async () => { + const { input } = setup(); + + await expect(client.queueDeletionRequest(input)).rejects.toThrow(Error); + }); + }); + + describe('when received no deletionPlannedAt in a response', () => { + const setup = () => { + const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); + + const response: AxiosResponse = axiosResponseFactory.build({ + data: { + requestId: '6536ce29b595d7c8e5faf200', + }, + status: 202, + }); + + httpService.post.mockReturnValueOnce(of(response)); + + return { input }; + }; + + it('should throw an exception', async () => { + const { input } = setup(); + + await expect(client.queueDeletionRequest(input)).rejects.toThrow(Error); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/client/deletion.client.ts b/apps/server/src/modules/deletion/client/deletion.client.ts new file mode 100644 index 00000000000..66bb267d070 --- /dev/null +++ b/apps/server/src/modules/deletion/client/deletion.client.ts @@ -0,0 +1,66 @@ +import { firstValueFrom } from 'rxjs'; +import { AxiosResponse } from 'axios'; +import { Injectable } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { DeletionRequestInput, DeletionRequestOutput, DeletionClientConfig } from './interface'; + +@Injectable() +export class DeletionClient { + private readonly baseUrl: string; + + private readonly apiKey: string; + + private readonly postDeletionRequestsEndpoint: string; + + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService + ) { + this.baseUrl = this.configService.get('ADMIN_API_CLIENT_BASE_URL'); + this.apiKey = this.configService.get('ADMIN_API_CLIENT_API_KEY'); + + // Prepare the POST /deletionRequests endpoint beforehand to not do it on every client call. + this.postDeletionRequestsEndpoint = new URL('/admin/api/v1/deletionRequests', this.baseUrl).toString(); + } + + async queueDeletionRequest(input: DeletionRequestInput): Promise { + const request = this.httpService.post(this.postDeletionRequestsEndpoint, input, this.defaultHeaders()); + + return firstValueFrom(request) + .then((resp: AxiosResponse) => { + // Throw an error if any other status code (other than expected "202 Accepted" is returned). + if (resp.status !== 202) { + throw new Error(`invalid HTTP status code in a response from the server - ${resp.status} instead of 202`); + } + + // Throw an error if server didn't return a requestId in a response (and it is + // required as it gives client the reference to the created deletion request). + if (!resp.data.requestId) { + throw new Error('no valid requestId returned from the server'); + } + + // Throw an error if server didn't return a deletionPlannedAt timestamp so the user + // will not be aware after which date the deletion request's execution will begin. + if (!resp.data.deletionPlannedAt) { + throw new Error('no valid deletionPlannedAt returned from the server'); + } + + return resp.data; + }) + .catch((err: Error) => { + // Throw an error if sending/processing deletion request by the client failed in any way. + throw new Error(`failed to send/process a deletion request: ${err.toString()}`); + }); + } + + private apiKeyHeader() { + return { 'X-Api-Key': this.apiKey }; + } + + private defaultHeaders() { + return { + headers: this.apiKeyHeader(), + }; + } +} diff --git a/apps/server/src/modules/deletion/client/index.ts b/apps/server/src/modules/deletion/client/index.ts new file mode 100644 index 00000000000..fde3db98f3b --- /dev/null +++ b/apps/server/src/modules/deletion/client/index.ts @@ -0,0 +1,3 @@ +export * from './interface'; +export * from './builder'; +export * from './deletion.client'; diff --git a/apps/server/src/modules/deletion/client/interface/deletion-client-config.interface.ts b/apps/server/src/modules/deletion/client/interface/deletion-client-config.interface.ts new file mode 100644 index 00000000000..8178515b6d4 --- /dev/null +++ b/apps/server/src/modules/deletion/client/interface/deletion-client-config.interface.ts @@ -0,0 +1,4 @@ +export interface DeletionClientConfig { + ADMIN_API_CLIENT_BASE_URL: string; + ADMIN_API_CLIENT_API_KEY: string; +} diff --git a/apps/server/src/modules/deletion/client/interface/deletion-request-input.interface.ts b/apps/server/src/modules/deletion/client/interface/deletion-request-input.interface.ts new file mode 100644 index 00000000000..4879ce4d972 --- /dev/null +++ b/apps/server/src/modules/deletion/client/interface/deletion-request-input.interface.ts @@ -0,0 +1,6 @@ +import { DeletionRequestTargetRefInput } from './deletion-request-target-ref-input.interface'; + +export interface DeletionRequestInput { + targetRef: DeletionRequestTargetRefInput; + deleteInMinutes?: number; +} diff --git a/apps/server/src/modules/deletion/client/interface/deletion-request-output.interface.ts b/apps/server/src/modules/deletion/client/interface/deletion-request-output.interface.ts new file mode 100644 index 00000000000..b61a372d5a5 --- /dev/null +++ b/apps/server/src/modules/deletion/client/interface/deletion-request-output.interface.ts @@ -0,0 +1,4 @@ +export interface DeletionRequestOutput { + requestId: string; + deletionPlannedAt: Date; +} diff --git a/apps/server/src/modules/deletion/client/interface/deletion-request-target-ref-input.interface.ts b/apps/server/src/modules/deletion/client/interface/deletion-request-target-ref-input.interface.ts new file mode 100644 index 00000000000..603bb0c13ec --- /dev/null +++ b/apps/server/src/modules/deletion/client/interface/deletion-request-target-ref-input.interface.ts @@ -0,0 +1,4 @@ +export interface DeletionRequestTargetRefInput { + domain: string; + id: string; +} diff --git a/apps/server/src/modules/deletion/client/interface/index.ts b/apps/server/src/modules/deletion/client/interface/index.ts new file mode 100644 index 00000000000..38f0f639731 --- /dev/null +++ b/apps/server/src/modules/deletion/client/interface/index.ts @@ -0,0 +1,4 @@ +export * from './deletion-client-config.interface'; +export * from './deletion-request-target-ref-input.interface'; +export * from './deletion-request-input.interface'; +export * from './deletion-request-output.interface'; diff --git a/apps/server/src/modules/deletion/console/builder/index.ts b/apps/server/src/modules/deletion/console/builder/index.ts new file mode 100644 index 00000000000..12fd0997ebe --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/index.ts @@ -0,0 +1 @@ +export * from './push-delete-requests-options.builder'; diff --git a/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.spec.ts b/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.spec.ts new file mode 100644 index 00000000000..5c83defdd1e --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.spec.ts @@ -0,0 +1,43 @@ +import { PushDeletionRequestsOptions } from '../interface'; +import { PushDeleteRequestsOptionsBuilder } from './push-delete-requests-options.builder'; + +describe(PushDeleteRequestsOptionsBuilder.name, () => { + describe(PushDeleteRequestsOptionsBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const refsFilePath = '/tmp/ids.txt'; + const targetRefDomain = 'school'; + const deleteInMinutes = 43200; + const callsDelayMs = 100; + + const expectedOutput: PushDeletionRequestsOptions = { + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs, + }; + + return { + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs, + expectedOutput, + }; + }; + + it('should return valid object with expected values', () => { + const { refsFilePath, targetRefDomain, deleteInMinutes, callsDelayMs, expectedOutput } = setup(); + + const output = PushDeleteRequestsOptionsBuilder.build( + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs + ); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.ts b/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.ts new file mode 100644 index 00000000000..f8ceae9263d --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.ts @@ -0,0 +1,17 @@ +import { PushDeletionRequestsOptions } from '../interface'; + +export class PushDeleteRequestsOptionsBuilder { + static build( + refsFilePath: string, + targetRefDomain: string, + deleteInMinutes: number, + callsDelayMs: number + ): PushDeletionRequestsOptions { + return { + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs, + }; + } +} diff --git a/apps/server/src/modules/deletion/console/deletion-console.module.ts b/apps/server/src/modules/deletion/console/deletion-console.module.ts new file mode 100644 index 00000000000..0585b3631da --- /dev/null +++ b/apps/server/src/modules/deletion/console/deletion-console.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { ConfigModule } from '@nestjs/config'; +import { ConsoleModule } from 'nestjs-console'; +import { ConsoleWriterModule } from '@infra/console'; +import { createConfigModuleOptions } from '@src/config'; +import { DeletionClient } from '../client'; +import { getDeletionClientConfig } from '../client/deletion-client.config'; +import { BatchDeletionService } from '../services'; +import { BatchDeletionUc } from '../uc'; +import { DeletionQueueConsole } from './deletion-queue.console'; + +@Module({ + imports: [ + ConsoleModule, + ConsoleWriterModule, + HttpModule, + ConfigModule.forRoot(createConfigModuleOptions(getDeletionClientConfig)), + ], + providers: [DeletionClient, BatchDeletionService, BatchDeletionUc, DeletionQueueConsole], +}) +export class DeletionConsoleModule {} diff --git a/apps/server/src/modules/deletion/console/deletion-queue.console.spec.ts b/apps/server/src/modules/deletion/console/deletion-queue.console.spec.ts new file mode 100644 index 00000000000..61f9cf0ff53 --- /dev/null +++ b/apps/server/src/modules/deletion/console/deletion-queue.console.spec.ts @@ -0,0 +1,79 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConsoleWriterService } from '@infra/console'; +import { createMock } from '@golevelup/ts-jest'; +import { BatchDeletionUc } from '../uc'; +import { DeletionQueueConsole } from './deletion-queue.console'; +import { PushDeleteRequestsOptionsBuilder } from './builder'; + +describe(DeletionQueueConsole.name, () => { + let module: TestingModule; + let console: DeletionQueueConsole; + let batchDeletionUc: BatchDeletionUc; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionQueueConsole, + { + provide: ConsoleWriterService, + useValue: createMock(), + }, + { + provide: BatchDeletionUc, + useValue: createMock(), + }, + ], + }).compile(); + + console = module.get(DeletionQueueConsole); + batchDeletionUc = module.get(BatchDeletionUc); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('console should be defined', () => { + expect(console).toBeDefined(); + }); + + describe('pushDeletionRequests', () => { + describe('when called with valid options', () => { + const setup = () => { + const refsFilePath = '/tmp/ids.txt'; + const targetRefDomain = 'school'; + const deleteInMinutes = 43200; + const callsDelayMs = 100; + + const options = PushDeleteRequestsOptionsBuilder.build( + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs + ); + + return { + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs, + options, + }; + }; + + it(`should call ${BatchDeletionUc.name} with proper arguments`, async () => { + const { refsFilePath, targetRefDomain, deleteInMinutes, callsDelayMs, options } = setup(); + + const spy = jest.spyOn(batchDeletionUc, 'deleteRefsFromTxtFile'); + + await console.pushDeletionRequests(options); + + expect(spy).toBeCalledWith(refsFilePath, targetRefDomain, deleteInMinutes, callsDelayMs); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/console/deletion-queue.console.ts b/apps/server/src/modules/deletion/console/deletion-queue.console.ts new file mode 100644 index 00000000000..c8b133dba84 --- /dev/null +++ b/apps/server/src/modules/deletion/console/deletion-queue.console.ts @@ -0,0 +1,46 @@ +import { Console, Command } from 'nestjs-console'; +import { ConsoleWriterService } from '@infra/console'; +import { BatchDeletionUc } from '../uc'; +import { PushDeletionRequestsOptions } from './interface'; + +@Console({ command: 'queue', description: 'Console providing an access to the deletion queue.' }) +export class DeletionQueueConsole { + constructor(private consoleWriter: ConsoleWriterService, private batchDeletionUc: BatchDeletionUc) {} + + @Command({ + command: 'push', + description: 'Push new deletion requests to the deletion queue.', + options: [ + { + flags: '-rfp, --refsFilePath ', + description: 'Path of the file containing all the references to the data that should be deleted.', + required: true, + }, + { + flags: '-trd, --targetRefDomain ', + description: 'Name of the target ref domain.', + required: false, + }, + { + flags: '-dim, --deleteInMinutes ', + description: 'Number of minutes after which the data deletion process should begin.', + required: false, + }, + { + flags: '-cdm, --callsDelayMs ', + description: 'Delay between all the performed client calls, in milliseconds.', + required: false, + }, + ], + }) + async pushDeletionRequests(options: PushDeletionRequestsOptions): Promise { + const summary = await this.batchDeletionUc.deleteRefsFromTxtFile( + options.refsFilePath, + options.targetRefDomain, + options.deleteInMinutes ? Number(options.deleteInMinutes) : undefined, + options.callsDelayMs ? Number(options.callsDelayMs) : undefined + ); + + this.consoleWriter.info(JSON.stringify(summary)); + } +} diff --git a/apps/server/src/modules/deletion/console/index.ts b/apps/server/src/modules/deletion/console/index.ts new file mode 100644 index 00000000000..db47bd8c99f --- /dev/null +++ b/apps/server/src/modules/deletion/console/index.ts @@ -0,0 +1 @@ +export * from './deletion-console.module'; diff --git a/apps/server/src/modules/deletion/console/interface/index.ts b/apps/server/src/modules/deletion/console/interface/index.ts new file mode 100644 index 00000000000..2fcb281430f --- /dev/null +++ b/apps/server/src/modules/deletion/console/interface/index.ts @@ -0,0 +1 @@ +export * from './push-delete-requests-options.interface'; diff --git a/apps/server/src/modules/deletion/console/interface/push-delete-requests-options.interface.ts b/apps/server/src/modules/deletion/console/interface/push-delete-requests-options.interface.ts new file mode 100644 index 00000000000..a54f652cf94 --- /dev/null +++ b/apps/server/src/modules/deletion/console/interface/push-delete-requests-options.interface.ts @@ -0,0 +1,6 @@ +export interface PushDeletionRequestsOptions { + refsFilePath: string; + targetRefDomain: string; + deleteInMinutes: number; + callsDelayMs: number; +} diff --git a/apps/server/src/modules/deletion/index.ts b/apps/server/src/modules/deletion/index.ts index bd89c1e8d84..793f306e7a0 100644 --- a/apps/server/src/modules/deletion/index.ts +++ b/apps/server/src/modules/deletion/index.ts @@ -1,2 +1,4 @@ export * from './deletion.module'; export * from './services'; +export * from './client'; +export * from './console'; diff --git a/apps/server/src/modules/deletion/services/batch-deletion.service.spec.ts b/apps/server/src/modules/deletion/services/batch-deletion.service.spec.ts new file mode 100644 index 00000000000..640e290af1a --- /dev/null +++ b/apps/server/src/modules/deletion/services/batch-deletion.service.spec.ts @@ -0,0 +1,97 @@ +import { ObjectId } from 'bson'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { DeletionClient, DeletionRequestOutput, DeletionRequestOutputBuilder } from '../client'; +import { QueueDeletionRequestInputBuilder, QueueDeletionRequestOutputBuilder } from './builder'; +import { BatchDeletionService } from './batch-deletion.service'; + +describe(BatchDeletionService.name, () => { + let module: TestingModule; + let service: BatchDeletionService; + let deletionClient: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + BatchDeletionService, + { + provide: DeletionClient, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(BatchDeletionService); + deletionClient = module.get(DeletionClient); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('service should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('queueDeletionRequests', () => { + describe('when called with valid inputs array and a requested delay between the client calls', () => { + describe("when client doesn't throw any error", () => { + const setup = () => { + const inputs = [QueueDeletionRequestInputBuilder.build('user', new ObjectId().toHexString(), 60)]; + + const requestId = new ObjectId().toHexString(); + const deletionPlannedAt = new Date(); + + const queueDeletionRequestOutput: DeletionRequestOutput = DeletionRequestOutputBuilder.build( + requestId, + deletionPlannedAt + ); + + deletionClient.queueDeletionRequest.mockResolvedValueOnce(queueDeletionRequestOutput); + + const expectedOutput = QueueDeletionRequestOutputBuilder.buildSuccess(requestId, deletionPlannedAt); + + const expectedOutputs = [expectedOutput]; + + return { inputs, expectedOutputs }; + }; + + it('should return an output object with successful status info', async () => { + const { inputs, expectedOutputs } = setup(); + + const outputs = await service.queueDeletionRequests(inputs, 1); + + expect(outputs).toStrictEqual(expectedOutputs); + }); + }); + + describe('when client throws an error', () => { + const setup = () => { + const inputs = [QueueDeletionRequestInputBuilder.build('user', new ObjectId().toHexString(), 60)]; + + const error = new Error('connection error'); + + deletionClient.queueDeletionRequest.mockRejectedValueOnce(error); + + const expectedOutput = QueueDeletionRequestOutputBuilder.buildError(error); + + const expectedOutputs = [expectedOutput]; + + return { inputs, expectedOutputs }; + }; + + it('should return an output object with failure status info', async () => { + const { inputs, expectedOutputs } = setup(); + + const outputs = await service.queueDeletionRequests(inputs); + + expect(outputs).toStrictEqual(expectedOutputs); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/services/batch-deletion.service.ts b/apps/server/src/modules/deletion/services/batch-deletion.service.ts new file mode 100644 index 00000000000..d8c14f315a0 --- /dev/null +++ b/apps/server/src/modules/deletion/services/batch-deletion.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { QueueDeletionRequestOutputBuilder } from './builder'; +import { DeletionClient, DeletionRequestInputBuilder } from '../client'; +import { QueueDeletionRequestInput, QueueDeletionRequestOutput } from './interface'; + +@Injectable() +export class BatchDeletionService { + constructor(private readonly deletionClient: DeletionClient) {} + + async queueDeletionRequests( + inputs: QueueDeletionRequestInput[], + callsDelayMilliseconds?: number + ): Promise { + const outputs: QueueDeletionRequestOutput[] = []; + + // For every provided deletion request input, try to queue it via deletion client. + // In any case, add the result of the trial to the outputs - it will be either a valid + // response in a form of a requestId + deletionPlannedAt values pair or some error + // returned from the client. In any case, every input should be processed. + for (const input of inputs) { + const deletionRequestInput = DeletionRequestInputBuilder.build( + input.targetRefDomain, + input.targetRefId, + input.deleteInMinutes + ); + + try { + // eslint-disable-next-line no-await-in-loop + const deletionRequestOutput = await this.deletionClient.queueDeletionRequest(deletionRequestInput); + + // In case of a successful client response, add the + // requestId + deletionPlannedAt values pair to the outputs. + outputs.push( + QueueDeletionRequestOutputBuilder.buildSuccess( + deletionRequestOutput.requestId, + deletionRequestOutput.deletionPlannedAt + ) + ); + } catch (err) { + // In case of a failure client response, add the full error message to the outputs. + outputs.push(QueueDeletionRequestOutputBuilder.buildError(err as Error)); + } + + // If any delay between the client calls has been requested, "sleep" for the specified amount of time. + if (callsDelayMilliseconds && callsDelayMilliseconds > 0) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, callsDelayMilliseconds); + }); + } + } + + return outputs; + } +} diff --git a/apps/server/src/modules/deletion/services/builder/index.ts b/apps/server/src/modules/deletion/services/builder/index.ts new file mode 100644 index 00000000000..acd85a37989 --- /dev/null +++ b/apps/server/src/modules/deletion/services/builder/index.ts @@ -0,0 +1,2 @@ +export * from './queue-deletion-request-input.builder'; +export * from './queue-deletion-request-output.builder'; diff --git a/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.spec.ts b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.spec.ts new file mode 100644 index 00000000000..e5d87858156 --- /dev/null +++ b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.spec.ts @@ -0,0 +1,27 @@ +import { ObjectId } from 'bson'; +import { QueueDeletionRequestInput } from '../interface'; +import { QueueDeletionRequestInputBuilder } from './queue-deletion-request-input.builder'; + +describe(QueueDeletionRequestInputBuilder.name, () => { + describe(QueueDeletionRequestInputBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const targetRefDomain = 'user'; + const targetRefId = new ObjectId().toHexString(); + const deleteInMinutes = 60; + + const expectedOutput: QueueDeletionRequestInput = { targetRefDomain, targetRefId, deleteInMinutes }; + + return { targetRefDomain, targetRefId, deleteInMinutes, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { targetRefDomain, targetRefId, deleteInMinutes, expectedOutput } = setup(); + + const output = QueueDeletionRequestInputBuilder.build(targetRefDomain, targetRefId, deleteInMinutes); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.ts b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.ts new file mode 100644 index 00000000000..a7fff2152b9 --- /dev/null +++ b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.ts @@ -0,0 +1,7 @@ +import { QueueDeletionRequestInput } from '../interface'; + +export class QueueDeletionRequestInputBuilder { + static build(targetRefDomain: string, targetRefId: string, deleteInMinutes: number): QueueDeletionRequestInput { + return { targetRefDomain, targetRefId, deleteInMinutes }; + } +} diff --git a/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.spec.ts b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.spec.ts new file mode 100644 index 00000000000..cd835a9cf4a --- /dev/null +++ b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.spec.ts @@ -0,0 +1,46 @@ +import { ObjectId } from 'bson'; +import { QueueDeletionRequestOutput } from '../interface'; +import { QueueDeletionRequestOutputBuilder } from './queue-deletion-request-output.builder'; + +describe(QueueDeletionRequestOutputBuilder.name, () => { + describe(QueueDeletionRequestOutputBuilder.buildSuccess.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const requestId = new ObjectId().toHexString(); + const deletionPlannedAt = new Date(); + + const expectedOutput: QueueDeletionRequestOutput = { requestId, deletionPlannedAt }; + + return { requestId, deletionPlannedAt, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { requestId, deletionPlannedAt, expectedOutput } = setup(); + + const output = QueueDeletionRequestOutputBuilder.buildSuccess(requestId, deletionPlannedAt); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); + + describe(QueueDeletionRequestOutputBuilder.buildError.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const error = new Error('test error message'); + + const expectedOutput: QueueDeletionRequestOutput = { error: error.toString() }; + + return { error, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { error, expectedOutput } = setup(); + + const output = QueueDeletionRequestOutputBuilder.buildError(error); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.ts b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.ts new file mode 100644 index 00000000000..c44e503cbaf --- /dev/null +++ b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.ts @@ -0,0 +1,29 @@ +import { QueueDeletionRequestOutput } from '../interface'; + +export class QueueDeletionRequestOutputBuilder { + private static build(requestId?: string, deletionPlannedAt?: Date, error?: string): QueueDeletionRequestOutput { + const output: QueueDeletionRequestOutput = {}; + + if (requestId) { + output.requestId = requestId; + } + + if (deletionPlannedAt) { + output.deletionPlannedAt = deletionPlannedAt; + } + + if (error) { + output.error = error.toString(); + } + + return output; + } + + static buildSuccess(requestId: string, deletionPlannedAt: Date): QueueDeletionRequestOutput { + return this.build(requestId, deletionPlannedAt); + } + + static buildError(err: Error): QueueDeletionRequestOutput { + return this.build(undefined, undefined, err.toString()); + } +} diff --git a/apps/server/src/modules/deletion/services/index.ts b/apps/server/src/modules/deletion/services/index.ts index 9661354718c..52e0b6ba22d 100644 --- a/apps/server/src/modules/deletion/services/index.ts +++ b/apps/server/src/modules/deletion/services/index.ts @@ -1 +1,5 @@ +export * from './interface'; +export * from './builder'; +export * from './references.service'; +export * from './batch-deletion.service'; export * from './deletion-request.service'; diff --git a/apps/server/src/modules/deletion/services/interface/index.ts b/apps/server/src/modules/deletion/services/interface/index.ts new file mode 100644 index 00000000000..8a455440798 --- /dev/null +++ b/apps/server/src/modules/deletion/services/interface/index.ts @@ -0,0 +1,2 @@ +export * from './queue-deletion-request-input.interface'; +export * from './queue-deletion-request-output.interface'; diff --git a/apps/server/src/modules/deletion/services/interface/queue-deletion-request-input.interface.ts b/apps/server/src/modules/deletion/services/interface/queue-deletion-request-input.interface.ts new file mode 100644 index 00000000000..b421943bce9 --- /dev/null +++ b/apps/server/src/modules/deletion/services/interface/queue-deletion-request-input.interface.ts @@ -0,0 +1,5 @@ +export interface QueueDeletionRequestInput { + targetRefDomain: string; + targetRefId: string; + deleteInMinutes: number; +} diff --git a/apps/server/src/modules/deletion/services/interface/queue-deletion-request-output.interface.ts b/apps/server/src/modules/deletion/services/interface/queue-deletion-request-output.interface.ts new file mode 100644 index 00000000000..375ff811857 --- /dev/null +++ b/apps/server/src/modules/deletion/services/interface/queue-deletion-request-output.interface.ts @@ -0,0 +1,5 @@ +export interface QueueDeletionRequestOutput { + requestId?: string; + deletionPlannedAt?: Date; + error?: string; +} diff --git a/apps/server/src/modules/deletion/services/references.service.spec.ts b/apps/server/src/modules/deletion/services/references.service.spec.ts new file mode 100644 index 00000000000..26eb6e0c8d7 --- /dev/null +++ b/apps/server/src/modules/deletion/services/references.service.spec.ts @@ -0,0 +1,74 @@ +import fs from 'fs'; +import { ReferencesService } from './references.service'; + +describe(ReferencesService.name, () => { + describe(ReferencesService.loadFromTxtFile.name, () => { + const setup = (mockedFileContent: string) => { + jest.spyOn(fs, 'readFileSync').mockReturnValueOnce(mockedFileContent); + }; + + describe('when passed a completely empty file (without any content)', () => { + it('should return an empty references array', () => { + setup(''); + + const references = ReferencesService.loadFromTxtFile('references.txt'); + + expect(references).toEqual([]); + }); + }); + + describe('when passed a file without any references (just some empty lines)', () => { + it('should return an empty references array', () => { + setup('\n\n \n \n\n\n \n\n\n'); + + const references = ReferencesService.loadFromTxtFile('references.txt'); + + expect(references).toEqual([]); + }); + }); + + describe('when passed a file with 3 references on a few separate lines', () => { + describe('split with CRs', () => { + it('should return an array with all the references present in a file', () => { + setup('653fd3b784ca851b17e98579\r653fd3b784ca851b17e9857a\r653fd3b784ca851b17e9857b\n\n\n'); + + const references = ReferencesService.loadFromTxtFile('references.txt'); + + expect(references).toEqual([ + '653fd3b784ca851b17e98579', + '653fd3b784ca851b17e9857a', + '653fd3b784ca851b17e9857b', + ]); + }); + }); + + describe('split with LFs', () => { + it('should return an array with all the references present in a file', () => { + setup('653fd3b784ca851b17e98579\n653fd3b784ca851b17e9857a\n653fd3b784ca851b17e9857b\n\n\n'); + + const references = ReferencesService.loadFromTxtFile('references.txt'); + + expect(references).toEqual([ + '653fd3b784ca851b17e98579', + '653fd3b784ca851b17e9857a', + '653fd3b784ca851b17e9857b', + ]); + }); + }); + + describe('split with CRLFs', () => { + it('should return an array with all the references present in a file', () => { + setup('653fd3b784ca851b17e98579\r\n653fd3b784ca851b17e9857a\r\n653fd3b784ca851b17e9857b\r\n\r\n\r\n'); + + const references = ReferencesService.loadFromTxtFile('references.txt'); + + expect(references).toEqual([ + '653fd3b784ca851b17e98579', + '653fd3b784ca851b17e9857a', + '653fd3b784ca851b17e9857b', + ]); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/services/references.service.ts b/apps/server/src/modules/deletion/services/references.service.ts new file mode 100644 index 00000000000..991c36c02af --- /dev/null +++ b/apps/server/src/modules/deletion/services/references.service.ts @@ -0,0 +1,27 @@ +import fs from 'fs'; + +export class ReferencesService { + static loadFromTxtFile(filePath: string): string[] { + let fileContent = fs.readFileSync(filePath).toString(); + + // Replace all the CRLF occurrences with just a LF. + fileContent = fileContent.replace(/\r\n?/g, '\n'); + + // Split the whole file content by a line feed (LF) char (\n). + const fileLines = fileContent.split('\n'); + + const references: string[] = []; + + // Iterate over all the file lines and if it contains a valid id (which is + // basically any non-empty string), add it to the loaded references array. + fileLines.forEach((fileLine) => { + const reference = fileLine.trim(); + + if (reference && reference.length > 0) { + references.push(reference); + } + }); + + return references; + } +} diff --git a/apps/server/src/modules/deletion/uc/batch-deletion.uc.spec.ts b/apps/server/src/modules/deletion/uc/batch-deletion.uc.spec.ts new file mode 100644 index 00000000000..7292f36efb0 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/batch-deletion.uc.spec.ts @@ -0,0 +1,195 @@ +import { ObjectId } from 'bson'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { + BatchDeletionService, + QueueDeletionRequestInputBuilder, + QueueDeletionRequestOutput, + QueueDeletionRequestOutputBuilder, + ReferencesService, +} from '../services'; +import { BatchDeletionSummaryDetail, BatchDeletionSummaryOverallStatus } from './interface'; +import { BatchDeletionSummaryDetailBuilder } from './builder'; +import { BatchDeletionUc } from './batch-deletion.uc'; + +describe(BatchDeletionUc.name, () => { + let module: TestingModule; + let uc: BatchDeletionUc; + let batchDeletionService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + BatchDeletionUc, + { + provide: BatchDeletionService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(BatchDeletionUc); + batchDeletionService = module.get(BatchDeletionService); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('uc should be defined', () => { + expect(uc).toBeDefined(); + }); + + describe('deleteRefsFromTxtFile', () => { + describe('when called with valid arguments', () => { + describe('when batch deletion service returns an expected amount of outputs', () => { + describe('when only successful executions took place', () => { + const setup = () => { + const targetRefsCount = 3; + + const targetRefIds: string[] = []; + const outputs: QueueDeletionRequestOutput[] = []; + + for (let i = 0; i < targetRefsCount; i += 1) { + targetRefIds.push(new ObjectId().toHexString()); + outputs.push(QueueDeletionRequestOutputBuilder.buildSuccess(new ObjectId().toHexString(), new Date())); + } + + ReferencesService.loadFromTxtFile = jest.fn().mockReturnValueOnce(targetRefIds); + + batchDeletionService.queueDeletionRequests.mockResolvedValueOnce([outputs[0], outputs[1], outputs[2]]); + + const targetRefDomain = 'school'; + const deleteInMinutes = 60; + + const expectedSummaryFieldsDetails: BatchDeletionSummaryDetail[] = []; + + for (let i = 0; i < targetRefIds.length; i += 1) { + expectedSummaryFieldsDetails.push( + BatchDeletionSummaryDetailBuilder.build( + QueueDeletionRequestInputBuilder.build(targetRefDomain, targetRefIds[i], deleteInMinutes), + outputs[i] + ) + ); + } + + const expectedSummaryFields = { + overallStatus: BatchDeletionSummaryOverallStatus.SUCCESS, + successCount: 3, + failureCount: 0, + details: expectedSummaryFieldsDetails, + }; + + const refsFilePath = '/tmp/ids.txt'; + + return { refsFilePath, targetRefDomain, deleteInMinutes, expectedSummaryFields }; + }; + + it('should return proper summary with all the successes and a successful overall status', async () => { + const { refsFilePath, targetRefDomain, deleteInMinutes, expectedSummaryFields } = setup(); + + const summary = await uc.deleteRefsFromTxtFile(refsFilePath, targetRefDomain, deleteInMinutes); + + expect(summary.executionTimeMilliseconds).toBeGreaterThan(0); + expect(summary).toMatchObject(expectedSummaryFields); + }); + }); + + describe('when both successful and failed executions took place', () => { + const setup = () => { + const targetRefsCount = 3; + + const targetRefIds: string[] = []; + + for (let i = 0; i < targetRefsCount; i += 1) { + targetRefIds.push(new ObjectId().toHexString()); + } + + const targetRefDomain = 'school'; + const deleteInMinutes = 60; + + ReferencesService.loadFromTxtFile = jest.fn().mockReturnValueOnce(targetRefIds); + + const outputs = [ + QueueDeletionRequestOutputBuilder.buildSuccess(new ObjectId().toHexString(), new Date()), + QueueDeletionRequestOutputBuilder.buildError(new Error('some error occurred...')), + QueueDeletionRequestOutputBuilder.buildSuccess(new ObjectId().toHexString(), new Date()), + ]; + + batchDeletionService.queueDeletionRequests.mockResolvedValueOnce([outputs[0], outputs[1], outputs[2]]); + + const expectedSummaryFieldsDetails: BatchDeletionSummaryDetail[] = []; + + for (let i = 0; i < targetRefIds.length; i += 1) { + expectedSummaryFieldsDetails.push( + BatchDeletionSummaryDetailBuilder.build( + QueueDeletionRequestInputBuilder.build(targetRefDomain, targetRefIds[i], deleteInMinutes), + outputs[i] + ) + ); + } + + const expectedSummaryFields = { + overallStatus: BatchDeletionSummaryOverallStatus.FAILURE, + successCount: 2, + failureCount: 1, + details: expectedSummaryFieldsDetails, + }; + + const refsFilePath = '/tmp/ids.txt'; + + return { refsFilePath, targetRefDomain, deleteInMinutes, expectedSummaryFields }; + }; + + it('should return proper summary with all the successes and failures', async () => { + const { refsFilePath, targetRefDomain, deleteInMinutes, expectedSummaryFields } = setup(); + + const summary = await uc.deleteRefsFromTxtFile(refsFilePath, targetRefDomain, deleteInMinutes); + + expect(summary.executionTimeMilliseconds).toBeGreaterThan(0); + expect(summary).toMatchObject(expectedSummaryFields); + }); + }); + }); + + describe('when batch deletion service returns an invalid amount of outputs', () => { + const setup = () => { + const targetRefsCount = 3; + + const targetRefIds: string[] = []; + + for (let i = 0; i < targetRefsCount; i += 1) { + targetRefIds.push(new ObjectId().toHexString()); + } + + ReferencesService.loadFromTxtFile = jest.fn().mockReturnValueOnce(targetRefIds); + + const outputs: QueueDeletionRequestOutput[] = []; + + for (let i = 0; i < targetRefsCount - 1; i += 1) { + targetRefIds.push(new ObjectId().toHexString()); + outputs.push(QueueDeletionRequestOutputBuilder.buildSuccess(new ObjectId().toHexString(), new Date())); + } + + batchDeletionService.queueDeletionRequests.mockResolvedValueOnce(outputs); + + const refsFilePath = '/tmp/ids.txt'; + + return { refsFilePath }; + }; + + it('should throw an error', async () => { + const { refsFilePath } = setup(); + + const func = () => uc.deleteRefsFromTxtFile(refsFilePath); + + await expect(func()).rejects.toThrow(); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/batch-deletion.uc.ts b/apps/server/src/modules/deletion/uc/batch-deletion.uc.ts new file mode 100644 index 00000000000..258b1b53f65 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/batch-deletion.uc.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@nestjs/common'; +import { BatchDeletionSummaryBuilder, BatchDeletionSummaryDetailBuilder } from './builder'; +import { + ReferencesService, + BatchDeletionService, + QueueDeletionRequestInput, + QueueDeletionRequestInputBuilder, +} from '../services'; +import { BatchDeletionSummary, BatchDeletionSummaryOverallStatus } from './interface'; + +@Injectable() +export class BatchDeletionUc { + constructor(private readonly batchDeletionService: BatchDeletionService) {} + + async deleteRefsFromTxtFile( + refsFilePath: string, + targetRefDomain = 'user', + deleteInMinutes = 43200, // 43200 minutes = 720 hours = 30 days + callsDelayMilliseconds?: number + ): Promise { + // First, load all the references from the provided text file (with given path). + const refsFromTxtFile = ReferencesService.loadFromTxtFile(refsFilePath); + + const inputs: QueueDeletionRequestInput[] = []; + + // For each reference found in a given file, add it to the inputs + // array (with added targetRefDomain and deleteInMinutes fields). + refsFromTxtFile.forEach((ref) => + inputs.push(QueueDeletionRequestInputBuilder.build(targetRefDomain, ref, deleteInMinutes)) + ); + + // Measure the overall queueing execution time by setting the start... + const startTime = performance.now(); + + const outputs = await this.batchDeletionService.queueDeletionRequests(inputs, callsDelayMilliseconds); + + // ...and end timestamps before and after the batch deletion service method execution. + const endTime = performance.now(); + + // Throw an error if the returned outputs number doesn't match the returned inputs number. + if (outputs.length !== inputs.length) { + throw new Error( + 'invalid result from the batch deletion service - expected to ' + + 'receive the same amount of outputs as the provided inputs, ' + + `instead received ${outputs.length} outputs for ${inputs.length} inputs` + ); + } + + const summary: BatchDeletionSummary = BatchDeletionSummaryBuilder.build(endTime - startTime); + + // Go through every received output and, in case of an error presence increase + // a failure count or, in case of no error, increase a success count. + for (let i = 0; i < outputs.length; i += 1) { + if (outputs[i].error) { + summary.failureCount += 1; + } else { + summary.successCount += 1; + } + + // Also add all the processed inputs and outputs details to the overall summary. + summary.details.push(BatchDeletionSummaryDetailBuilder.build(inputs[i], outputs[i])); + } + + // If no failure has been spotted, assume an overall success. + if (summary.failureCount === 0) { + summary.overallStatus = BatchDeletionSummaryOverallStatus.SUCCESS; + } + + return summary; + } +} diff --git a/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.spec.ts b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.spec.ts new file mode 100644 index 00000000000..43ea82e86d5 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.spec.ts @@ -0,0 +1,69 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BatchDeletionSummaryDetail } from '..'; +import { QueueDeletionRequestInput, QueueDeletionRequestOutput } from '../../services'; +import { BatchDeletionSummaryDetailBuilder } from './batch-deletion-summary-detail.builder'; + +describe(BatchDeletionSummaryDetailBuilder.name, () => { + describe(BatchDeletionSummaryDetailBuilder.build.name, () => { + describe('when called with proper arguments for', () => { + describe('a successful output case', () => { + const setup = () => { + const deletionRequestInput: QueueDeletionRequestInput = { + targetRefDomain: 'user', + targetRefId: new ObjectId().toHexString(), + deleteInMinutes: 1440, + }; + + const deletionRequestOutput: QueueDeletionRequestOutput = { + requestId: new ObjectId().toHexString(), + deletionPlannedAt: new Date(), + }; + + const expectedOutput: BatchDeletionSummaryDetail = { + input: deletionRequestInput, + output: deletionRequestOutput, + }; + + return { deletionRequestInput, deletionRequestOutput, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { deletionRequestInput, deletionRequestOutput, expectedOutput } = setup(); + + const output = BatchDeletionSummaryDetailBuilder.build(deletionRequestInput, deletionRequestOutput); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + + describe('an error output case', () => { + const setup = () => { + const deletionRequestInput: QueueDeletionRequestInput = { + targetRefDomain: 'user', + targetRefId: new ObjectId().toHexString(), + deleteInMinutes: 1440, + }; + + const deletionRequestOutput: QueueDeletionRequestOutput = { + error: 'some error occurred...', + }; + + const expectedOutput: BatchDeletionSummaryDetail = { + input: deletionRequestInput, + output: deletionRequestOutput, + }; + + return { deletionRequestInput, deletionRequestOutput, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { deletionRequestInput, deletionRequestOutput, expectedOutput } = setup(); + + const output = BatchDeletionSummaryDetailBuilder.build(deletionRequestInput, deletionRequestOutput); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.ts b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.ts new file mode 100644 index 00000000000..9ebbce66171 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.ts @@ -0,0 +1,8 @@ +import { QueueDeletionRequestInput, QueueDeletionRequestOutput } from '../../services'; +import { BatchDeletionSummaryDetail } from '../interface'; + +export class BatchDeletionSummaryDetailBuilder { + static build(input: QueueDeletionRequestInput, output: QueueDeletionRequestOutput): BatchDeletionSummaryDetail { + return { input, output }; + } +} diff --git a/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.spec.ts b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.spec.ts new file mode 100644 index 00000000000..a2b534602d4 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.spec.ts @@ -0,0 +1,30 @@ +import { BatchDeletionSummary, BatchDeletionSummaryOverallStatus } from '../interface'; +import { BatchDeletionSummaryBuilder } from './batch-deletion-summary.builder'; + +describe(BatchDeletionSummaryBuilder.name, () => { + describe(BatchDeletionSummaryBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const executionTimeMilliseconds = 142; + + const expectedOutput: BatchDeletionSummary = { + executionTimeMilliseconds: 142, + overallStatus: BatchDeletionSummaryOverallStatus.FAILURE, + successCount: 0, + failureCount: 0, + details: [], + }; + + return { executionTimeMilliseconds, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { executionTimeMilliseconds, expectedOutput } = setup(); + + const output = BatchDeletionSummaryBuilder.build(executionTimeMilliseconds); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.ts b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.ts new file mode 100644 index 00000000000..57fa2bcccd9 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.ts @@ -0,0 +1,13 @@ +import { BatchDeletionSummary, BatchDeletionSummaryOverallStatus } from '../interface'; + +export class BatchDeletionSummaryBuilder { + static build(executionTimeMilliseconds: number): BatchDeletionSummary { + return { + executionTimeMilliseconds, + overallStatus: BatchDeletionSummaryOverallStatus.FAILURE, + successCount: 0, + failureCount: 0, + details: [], + }; + } +} diff --git a/apps/server/src/modules/deletion/uc/builder/index.ts b/apps/server/src/modules/deletion/uc/builder/index.ts index fb8a2a5e7ec..c7ac430fa63 100644 --- a/apps/server/src/modules/deletion/uc/builder/index.ts +++ b/apps/server/src/modules/deletion/uc/builder/index.ts @@ -2,3 +2,5 @@ export * from './deletion-log-statistic.builder'; export * from './deletion-request-body-props.builder'; export * from './deletion-request-log-response.builder'; export * from './deletion-target-ref.builder'; +export * from './batch-deletion-summary-detail.builder'; +export * from './batch-deletion-summary.builder'; diff --git a/apps/server/src/modules/deletion/uc/index.ts b/apps/server/src/modules/deletion/uc/index.ts index f83bf1549c8..3260e898ae6 100644 --- a/apps/server/src/modules/deletion/uc/index.ts +++ b/apps/server/src/modules/deletion/uc/index.ts @@ -1,3 +1,4 @@ export * from './deletion-request.uc'; export * from './builder'; export * from './interface'; +export * from './batch-deletion.uc'; diff --git a/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-detail.interface.ts b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-detail.interface.ts new file mode 100644 index 00000000000..4fe99c13fad --- /dev/null +++ b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-detail.interface.ts @@ -0,0 +1,6 @@ +import { QueueDeletionRequestInput, QueueDeletionRequestOutput } from '../../services'; + +export interface BatchDeletionSummaryDetail { + input: QueueDeletionRequestInput; + output: QueueDeletionRequestOutput; +} diff --git a/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-overall-status.enum.ts b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-overall-status.enum.ts new file mode 100644 index 00000000000..4ae91bdf70e --- /dev/null +++ b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-overall-status.enum.ts @@ -0,0 +1,4 @@ +export const enum BatchDeletionSummaryOverallStatus { + SUCCESS = 'success', + FAILURE = 'failure', +} diff --git a/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary.interface.ts b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary.interface.ts new file mode 100644 index 00000000000..ce633e164f1 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary.interface.ts @@ -0,0 +1,9 @@ +import { BatchDeletionSummaryDetail } from './batch-deletion-summary-detail.interface'; + +export interface BatchDeletionSummary { + executionTimeMilliseconds: number; + overallStatus: string; + successCount: number; + failureCount: number; + details: BatchDeletionSummaryDetail[]; +} diff --git a/apps/server/src/modules/deletion/uc/interface/index.ts b/apps/server/src/modules/deletion/uc/interface/index.ts index 95786098275..9dcf644f410 100644 --- a/apps/server/src/modules/deletion/uc/interface/index.ts +++ b/apps/server/src/modules/deletion/uc/interface/index.ts @@ -1 +1,4 @@ +export * from './batch-deletion-summary-overall-status.enum'; +export * from './batch-deletion-summary-detail.interface'; +export * from './batch-deletion-summary.interface'; export * from './interfaces'; diff --git a/config/default.schema.json b/config/default.schema.json index 9a7c75445ee..903aac324ca 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1366,6 +1366,23 @@ "description": "Number of simultaneously synchronized students, teachers and classes" } } + }, + "ADMIN_API_CLIENT": { + "type": "object", + "description": "Configuration of the schulcloud-server's admin API client.", + "properties": { + "BASE_URL": { + "type": "string", + "description": "Base URL of the Admin API." + }, + "API_KEY": { + "type": "string", + "description": "API key for accessing the Admin API." + } + }, + "default": { + "BASE_URL": "http://localhost:4030" + } } }, "required": [], diff --git a/nest-cli.json b/nest-cli.json index 73dea03c093..8ce5461bb6f 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -45,6 +45,15 @@ "tsConfigPath": "apps/server/tsconfig.app.json" } }, + "deletion-console": { + "type": "application", + "root": "apps/server", + "entryFile": "apps/deletion-console.app", + "sourceRoot": "apps/server/src", + "compilerOptions": { + "tsConfigPath": "apps/server/tsconfig.app.json" + } + }, "files-storage": { "type": "application", "root": "apps/server", diff --git a/package.json b/package.json index 057125e9756..7b85512165b 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,9 @@ "nest:start:console": "nest start console --", "nest:start:console:dev": "nest start console --watch --", "nest:start:console:debug": "nest start console --debug --watch --", + "nest:start:deletion-console": "nest start deletion-console --", + "nest:start:deletion-console:dev": "nest start deletion-console --watch --", + "nest:start:deletion-console:debug": "nest start deletion-console --debug --watch --", "nest:test": "npm run nest:test:cov && npm run nest:lint", "nest:test:all": "jest", "nest:test:unit": "jest \"^((?!\\.api\\.spec\\.ts).)*\\.spec\\.ts$\"",