diff --git a/ansible/roles/schulcloud-server-core/tasks/main.yml b/ansible/roles/schulcloud-server-core/tasks/main.yml index 64e257c5f59..e64021b5f67 100644 --- a/ansible/roles/schulcloud-server-core/tasks/main.yml +++ b/ansible/roles/schulcloud-server-core/tasks/main.yml @@ -37,6 +37,13 @@ template: onepassword.yml.j2 when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + - name: Admin API client secret (from 1Password) + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: onepassword-admin-api-client.yml.j2 + when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + - name: remove old migration Job kubernetes.core.k8s: kubeconfig: ~/.kube/config @@ -108,6 +115,12 @@ namespace: "{{ NAMESPACE }}" template: api-delete-s3-files-cronjob.yml.j2 + - name: Data deletion trigger CronJob + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: data-deletion-trigger-cronjob.yml.j2 + - name: AMQPFileStorageDeployment kubernetes.core.k8s: kubeconfig: ~/.kube/config diff --git a/ansible/roles/schulcloud-server-core/templates/data-deletion-trigger-cronjob.yml.j2 b/ansible/roles/schulcloud-server-core/templates/data-deletion-trigger-cronjob.yml.j2 new file mode 100644 index 00000000000..a0807973c43 --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/data-deletion-trigger-cronjob.yml.j2 @@ -0,0 +1,57 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + namespace: {{ NAMESPACE }} + labels: + app: data-deletion-trigger + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: data-deletion-trigger + app.kubernetes.io/component: data-deletion + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} + name: data-deletion-trigger-cronjob +spec: + concurrencyPolicy: Forbid + schedule: "{{ SERVER_DATA_DELETION_TRIGGER_CRONJOB_SCHEDULE|default("@hourly", true) }}" + jobTemplate: + metadata: + labels: + app: data-deletion-trigger + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: data-deletion-trigger + app.kubernetes.io/component: data-deletion + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} + spec: + template: + spec: + containers: + - name: data-deletion-trigger-cronjob + image: {{ SCHULCLOUD_SERVER_IMAGE }}:{{ SCHULCLOUD_SERVER_IMAGE_TAG }} + envFrom: + - secretRef: + name: admin-api-client-secret + command: ['/bin/sh', '-c'] + args: ['npm run nest:start:deletion-console -- execution trigger'] + resources: + limits: + cpu: {{ API_CPU_LIMITS|default("2000m", true) }} + memory: {{ API_MEMORY_LIMITS|default("2Gi", true) }} + requests: + cpu: {{ API_CPU_REQUESTS|default("100m", true) }} + memory: {{ API_MEMORY_REQUESTS|default("150Mi", true) }} + restartPolicy: OnFailure + metadata: + labels: + app: data-deletion-trigger + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: data-deletion-trigger + app.kubernetes.io/component: data-deletion + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} \ No newline at end of file diff --git a/ansible/roles/schulcloud-server-core/templates/onepassword-admin-api-client.yml.j2 b/ansible/roles/schulcloud-server-core/templates/onepassword-admin-api-client.yml.j2 new file mode 100644 index 00000000000..fe2be1d76a8 --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/onepassword-admin-api-client.yml.j2 @@ -0,0 +1,7 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: admin-api-client-secret + namespace: {{ NAMESPACE }} +spec: + itemPath: "vaults/{{ ONEPASSWORD_OPERATOR_VAULT }}/items/admin-api-client" diff --git a/apps/server/src/modules/deletion/client/deletion.client.spec.ts b/apps/server/src/modules/deletion/client/deletion.client.spec.ts index 096b1f9b082..478f23a2348 100644 --- a/apps/server/src/modules/deletion/client/deletion.client.spec.ts +++ b/apps/server/src/modules/deletion/client/deletion.client.spec.ts @@ -1,4 +1,4 @@ -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { AxiosResponse } from 'axios'; import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; @@ -51,6 +51,23 @@ describe(DeletionClient.name, () => { }); describe('queueDeletionRequest', () => { + describe('when sending the HTTP request failed', () => { + const setup = () => { + const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); + + const error = new Error('unknown error'); + httpService.post.mockReturnValueOnce(throwError(() => error)); + + return { input }; + }; + + it('should catch and throw an error', async () => { + const { input } = setup(); + + await expect(client.queueDeletionRequest(input)).rejects.toThrow(Error); + }); + }); + describe('when received valid response with expected HTTP status code', () => { const setup = () => { const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); @@ -151,4 +168,55 @@ describe(DeletionClient.name, () => { }); }); }); + + describe('executeDeletions', () => { + describe('when sending the HTTP request failed', () => { + const setup = () => { + const error = new Error('unknown error'); + httpService.post.mockReturnValueOnce(throwError(() => error)); + }; + + it('should catch and throw an error', async () => { + setup(); + + await expect(client.executeDeletions()).rejects.toThrow(Error); + }); + }); + + describe('when received valid response with expected HTTP status code', () => { + const setup = () => { + const limit = 10; + + const response: AxiosResponse = axiosResponseFactory.build({ + status: 204, + }); + + httpService.post.mockReturnValueOnce(of(response)); + + return { limit }; + }; + + it('should return proper output', async () => { + const { limit } = setup(); + + await expect(client.executeDeletions(limit)).resolves.not.toThrow(); + }); + }); + + describe('when received invalid HTTP status code in a response', () => { + const setup = () => { + const response: AxiosResponse = axiosResponseFactory.build({ + status: 200, + }); + + httpService.post.mockReturnValueOnce(of(response)); + }; + + it('should throw an exception', async () => { + setup(); + + await expect(client.executeDeletions()).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 index 66bb267d070..a3c47844656 100644 --- a/apps/server/src/modules/deletion/client/deletion.client.ts +++ b/apps/server/src/modules/deletion/client/deletion.client.ts @@ -1,9 +1,9 @@ -import { firstValueFrom } from 'rxjs'; -import { AxiosResponse } from 'axios'; -import { Injectable } from '@nestjs/common'; import { HttpService } from '@nestjs/axios'; +import { BadGatewayException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { DeletionRequestInput, DeletionRequestOutput, DeletionClientConfig } from './interface'; +import { ErrorUtils } from '@src/core/error/utils'; +import { firstValueFrom } from 'rxjs'; +import { DeletionClientConfig, DeletionRequestInput, DeletionRequestOutput } from './interface'; @Injectable() export class DeletionClient { @@ -13,6 +13,8 @@ export class DeletionClient { private readonly postDeletionRequestsEndpoint: string; + private readonly postDeletionExecutionsEndpoint: string; + constructor( private readonly httpService: HttpService, private readonly configService: ConfigService @@ -22,36 +24,65 @@ export class DeletionClient { // 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(); + this.postDeletionExecutionsEndpoint = new URL('/admin/api/v1/deletionExecutions', 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()}`); - }); + try { + const request = this.httpService.post( + this.postDeletionRequestsEndpoint, + input, + this.defaultHeaders() + ); + + const resp = await firstValueFrom(request); + + // 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 Error('no valid deletionPlannedAt returned from the server'); + } + + return resp.data; + } catch (err) { + // Throw an error if sending deletion request has failed. + throw new BadGatewayException('DeletionClient:queueDeletionRequest', ErrorUtils.createHttpExceptionOptions(err)); + } + } + + async executeDeletions(limit?: number): Promise { + let requestConfig = {}; + + if (limit && limit > 0) { + requestConfig = { ...this.defaultHeaders(), params: { limit } }; + } else { + requestConfig = { ...this.defaultHeaders() }; + } + + try { + const request = this.httpService.post(this.postDeletionExecutionsEndpoint, null, requestConfig); + + const resp = await firstValueFrom(request); + + if (resp.status !== 204) { + // Throw an error if any other status code (other than expected "204 No Content" is returned). + throw new Error(`invalid HTTP status code in a response from the server - ${resp.status} instead of 204`); + } + } catch (err) { + // Throw an error if sending deletion request(s) execution trigger has failed. + throw new BadGatewayException('DeletionClient:executeDeletions', ErrorUtils.createHttpExceptionOptions(err)); + } } private apiKeyHeader() { diff --git a/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.spec.ts b/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.spec.ts new file mode 100644 index 00000000000..b0217e6a2c2 --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.spec.ts @@ -0,0 +1,45 @@ +import { DeletionExecutionTriggerResult, DeletionExecutionTriggerStatus } from '../interface'; +import { DeletionExecutionTriggerResultBuilder } from './deletion-execution-trigger-result.builder'; + +describe(DeletionExecutionTriggerResultBuilder.name, () => { + describe(DeletionExecutionTriggerResultBuilder.buildSuccess.name, () => { + describe('when called', () => { + const setup = () => { + const expectedOutput: DeletionExecutionTriggerResult = { status: DeletionExecutionTriggerStatus.SUCCESS }; + + return { expectedOutput }; + }; + + it('should return valid object indicating success', () => { + const { expectedOutput } = setup(); + + const output = DeletionExecutionTriggerResultBuilder.buildSuccess(); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); + + describe(DeletionExecutionTriggerResultBuilder.buildFailure.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const error = new Error('test error message'); + + const expectedOutput: DeletionExecutionTriggerResult = { + status: DeletionExecutionTriggerStatus.FAILURE, + error: error.toString(), + }; + + return { error, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { error, expectedOutput } = setup(); + + const output = DeletionExecutionTriggerResultBuilder.buildFailure(error); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.ts b/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.ts new file mode 100644 index 00000000000..e660e6905ea --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.ts @@ -0,0 +1,21 @@ +import { DeletionExecutionTriggerResult, DeletionExecutionTriggerStatus } from '../interface'; + +export class DeletionExecutionTriggerResultBuilder { + private static build(status: DeletionExecutionTriggerStatus, error?: string): DeletionExecutionTriggerResult { + const output: DeletionExecutionTriggerResult = { status }; + + if (error) { + output.error = error; + } + + return output; + } + + static buildSuccess(): DeletionExecutionTriggerResult { + return this.build(DeletionExecutionTriggerStatus.SUCCESS); + } + + static buildFailure(err: Error): DeletionExecutionTriggerResult { + return this.build(DeletionExecutionTriggerStatus.FAILURE, err.toString()); + } +} diff --git a/apps/server/src/modules/deletion/console/builder/index.ts b/apps/server/src/modules/deletion/console/builder/index.ts index 12fd0997ebe..985edf66371 100644 --- a/apps/server/src/modules/deletion/console/builder/index.ts +++ b/apps/server/src/modules/deletion/console/builder/index.ts @@ -1 +1,3 @@ export * from './push-delete-requests-options.builder'; +export * from './trigger-deletion-execution-options.builder'; +export * from './deletion-execution-trigger-result.builder'; diff --git a/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.spec.ts b/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.spec.ts new file mode 100644 index 00000000000..21171adb405 --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.spec.ts @@ -0,0 +1,24 @@ +import { TriggerDeletionExecutionOptions } from '../interface'; +import { TriggerDeletionExecutionOptionsBuilder } from './trigger-deletion-execution-options.builder'; + +describe(TriggerDeletionExecutionOptionsBuilder.name, () => { + describe(TriggerDeletionExecutionOptionsBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const limit = 1000; + + const expectedOutput: TriggerDeletionExecutionOptions = { limit }; + + return { limit, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { limit, expectedOutput } = setup(); + + const output = TriggerDeletionExecutionOptionsBuilder.build(limit); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.ts b/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.ts new file mode 100644 index 00000000000..ed652006e9d --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.ts @@ -0,0 +1,7 @@ +import { TriggerDeletionExecutionOptions } from '../interface'; + +export class TriggerDeletionExecutionOptionsBuilder { + static build(limit: number): TriggerDeletionExecutionOptions { + return { limit }; + } +} diff --git a/apps/server/src/modules/deletion/console/deletion-console.module.ts b/apps/server/src/modules/deletion/console/deletion-console.module.ts index 0585b3631da..504c1c35885 100644 --- a/apps/server/src/modules/deletion/console/deletion-console.module.ts +++ b/apps/server/src/modules/deletion/console/deletion-console.module.ts @@ -7,8 +7,9 @@ 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 { BatchDeletionUc, DeletionExecutionUc } from '../uc'; import { DeletionQueueConsole } from './deletion-queue.console'; +import { DeletionExecutionConsole } from './deletion-execution.console'; @Module({ imports: [ @@ -17,6 +18,13 @@ import { DeletionQueueConsole } from './deletion-queue.console'; HttpModule, ConfigModule.forRoot(createConfigModuleOptions(getDeletionClientConfig)), ], - providers: [DeletionClient, BatchDeletionService, BatchDeletionUc, DeletionQueueConsole], + providers: [ + DeletionClient, + BatchDeletionService, + BatchDeletionUc, + DeletionExecutionUc, + DeletionQueueConsole, + DeletionExecutionConsole, + ], }) export class DeletionConsoleModule {} diff --git a/apps/server/src/modules/deletion/console/deletion-execution.console.spec.ts b/apps/server/src/modules/deletion/console/deletion-execution.console.spec.ts new file mode 100644 index 00000000000..39519bab6a9 --- /dev/null +++ b/apps/server/src/modules/deletion/console/deletion-execution.console.spec.ts @@ -0,0 +1,109 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ConsoleWriterService } from '@infra/console'; +import { DeletionExecutionUc } from '../uc'; +import { DeletionExecutionConsole } from './deletion-execution.console'; +import { DeletionExecutionTriggerResultBuilder, TriggerDeletionExecutionOptionsBuilder } from './builder'; + +describe(DeletionExecutionConsole.name, () => { + let module: TestingModule; + let console: DeletionExecutionConsole; + let deletionExecutionUc: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionExecutionConsole, + { + provide: ConsoleWriterService, + useValue: createMock(), + }, + { + provide: DeletionExecutionUc, + useValue: createMock(), + }, + ], + }).compile(); + + console = module.get(DeletionExecutionConsole); + deletionExecutionUc = module.get(DeletionExecutionUc); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('console should be defined', () => { + expect(console).toBeDefined(); + }); + + describe('triggerDeletionExecution', () => { + describe('when called with valid options', () => { + const setup = () => { + const limit = 1000; + + const options = TriggerDeletionExecutionOptionsBuilder.build(1000); + + return { limit, options }; + }; + + it(`should call ${DeletionExecutionUc.name} with proper arguments`, async () => { + const { limit, options } = setup(); + + const spy = jest.spyOn(deletionExecutionUc, 'triggerDeletionExecution'); + + await console.triggerDeletionExecution(options); + + expect(spy).toBeCalledWith(limit); + }); + }); + + describe(`when ${DeletionExecutionUc.name}'s triggerDeletionExecution() method doesn't throw an exception`, () => { + const setup = () => { + const options = TriggerDeletionExecutionOptionsBuilder.build(1000); + + deletionExecutionUc.triggerDeletionExecution.mockResolvedValueOnce(undefined); + + const spy = jest.spyOn(DeletionExecutionTriggerResultBuilder, 'buildSuccess'); + + return { options, spy }; + }; + + it('should prepare result indicating success', async () => { + const { options, spy } = setup(); + + await console.triggerDeletionExecution(options); + + expect(spy).toHaveBeenCalled(); + }); + }); + + describe(`when ${DeletionExecutionUc.name}'s triggerDeletionExecution() method throws an exception`, () => { + const setup = () => { + const options = TriggerDeletionExecutionOptionsBuilder.build(1000); + + const err = new Error('some error occurred...'); + + deletionExecutionUc.triggerDeletionExecution.mockRejectedValueOnce(err); + + // const spy = jest.spyOn(ErrorMapper, 'mapRpcErrorResponseToDomainError'); + + const spy = jest.spyOn(DeletionExecutionTriggerResultBuilder, 'buildFailure'); + + return { options, err, spy }; + }; + + it('should prepare result indicating failure', async () => { + const { options, err, spy } = setup(); + + await console.triggerDeletionExecution(options); + + expect(spy).toHaveBeenCalledWith(err); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/console/deletion-execution.console.ts b/apps/server/src/modules/deletion/console/deletion-execution.console.ts new file mode 100644 index 00000000000..d9fdabe0160 --- /dev/null +++ b/apps/server/src/modules/deletion/console/deletion-execution.console.ts @@ -0,0 +1,38 @@ +import { Command, Console } from 'nestjs-console'; +import { ConsoleWriterService } from '@infra/console'; +import { DeletionExecutionUc } from '../uc'; +import { DeletionExecutionTriggerResultBuilder } from './builder'; +import { DeletionExecutionTriggerResult, TriggerDeletionExecutionOptions } from './interface'; + +@Console({ command: 'execution', description: 'Console providing an access to the deletion execution(s).' }) +export class DeletionExecutionConsole { + constructor(private consoleWriter: ConsoleWriterService, private deletionExecutionUc: DeletionExecutionUc) {} + + @Command({ + command: 'trigger', + description: 'Trigger execution of deletion requests.', + options: [ + { + flags: '-l, --limit ', + description: 'Limit of the requested deletion executions that should be performed.', + required: false, + }, + ], + }) + async triggerDeletionExecution(options: TriggerDeletionExecutionOptions): Promise { + // Try to trigger the deletion execution(s) via Deletion API client, + // return successful status in case of a success, otherwise return + // a result with a failure status and a proper error message. + let result: DeletionExecutionTriggerResult; + + try { + await this.deletionExecutionUc.triggerDeletionExecution(options.limit ? Number(options.limit) : undefined); + + result = DeletionExecutionTriggerResultBuilder.buildSuccess(); + } catch (err) { + result = DeletionExecutionTriggerResultBuilder.buildFailure(err as Error); + } + + this.consoleWriter.info(JSON.stringify(result)); + } +} diff --git a/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-result.ts b/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-result.ts new file mode 100644 index 00000000000..787100ec048 --- /dev/null +++ b/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-result.ts @@ -0,0 +1,6 @@ +import { DeletionExecutionTriggerStatus } from './deletion-execution-trigger-status.enum'; + +export interface DeletionExecutionTriggerResult { + status: DeletionExecutionTriggerStatus; + error?: string; +} diff --git a/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-status.enum.ts b/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-status.enum.ts new file mode 100644 index 00000000000..2b241cf72fc --- /dev/null +++ b/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-status.enum.ts @@ -0,0 +1,4 @@ +export const enum DeletionExecutionTriggerStatus { + SUCCESS = 'success', + FAILURE = 'failure', +} diff --git a/apps/server/src/modules/deletion/console/interface/index.ts b/apps/server/src/modules/deletion/console/interface/index.ts index 2fcb281430f..b15a668b53e 100644 --- a/apps/server/src/modules/deletion/console/interface/index.ts +++ b/apps/server/src/modules/deletion/console/interface/index.ts @@ -1 +1,4 @@ export * from './push-delete-requests-options.interface'; +export * from './trigger-deletion-execution-options.interface'; +export * from './deletion-execution-trigger-status.enum'; +export * from './deletion-execution-trigger-result'; diff --git a/apps/server/src/modules/deletion/console/interface/trigger-deletion-execution-options.interface.ts b/apps/server/src/modules/deletion/console/interface/trigger-deletion-execution-options.interface.ts new file mode 100644 index 00000000000..b17aafa1112 --- /dev/null +++ b/apps/server/src/modules/deletion/console/interface/trigger-deletion-execution-options.interface.ts @@ -0,0 +1,3 @@ +export interface TriggerDeletionExecutionOptions { + limit: number; +} diff --git a/apps/server/src/modules/deletion/uc/deletion-execution.uc.spec.ts b/apps/server/src/modules/deletion/uc/deletion-execution.uc.spec.ts new file mode 100644 index 00000000000..39c8065645a --- /dev/null +++ b/apps/server/src/modules/deletion/uc/deletion-execution.uc.spec.ts @@ -0,0 +1,69 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { DeletionClient } from '../client'; +import { DeletionExecutionUc } from './deletion-execution.uc'; + +describe(DeletionExecutionUc.name, () => { + let module: TestingModule; + let uc: DeletionExecutionUc; + let deletionClient: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionExecutionUc, + { + provide: DeletionClient, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(DeletionExecutionUc); + deletionClient = module.get(DeletionClient); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('uc should be defined', () => { + expect(uc).toBeDefined(); + }); + + describe('triggerDeletionExecution', () => { + describe("when client doesn't throw any error", () => { + const setup = () => { + const limit = 1000; + + deletionClient.executeDeletions.mockResolvedValueOnce(undefined); + + return { limit }; + }; + + it('should also not throw an error', async () => { + const { limit } = setup(); + + await expect(uc.triggerDeletionExecution(limit)).resolves.not.toThrow(); + }); + }); + + describe('when client throws an error', () => { + const setup = () => { + const error = new Error('connection error'); + + deletionClient.executeDeletions.mockRejectedValueOnce(error); + }; + + it('should also throw an error', async () => { + setup(); + + await expect(uc.triggerDeletionExecution()).rejects.toThrow(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/deletion-execution.uc.ts b/apps/server/src/modules/deletion/uc/deletion-execution.uc.ts new file mode 100644 index 00000000000..ad4c90c567d --- /dev/null +++ b/apps/server/src/modules/deletion/uc/deletion-execution.uc.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { DeletionClient } from '../client'; + +@Injectable() +export class DeletionExecutionUc { + constructor(private readonly deletionClient: DeletionClient) {} + + async triggerDeletionExecution(limit?: number): Promise { + await this.deletionClient.executeDeletions(limit); + } +} diff --git a/apps/server/src/modules/deletion/uc/index.ts b/apps/server/src/modules/deletion/uc/index.ts index cf74de969e5..4b1451b563d 100644 --- a/apps/server/src/modules/deletion/uc/index.ts +++ b/apps/server/src/modules/deletion/uc/index.ts @@ -1,2 +1,3 @@ export * from './interface'; export * from './batch-deletion.uc'; +export * from './deletion-execution.uc';