diff --git a/README.md b/README.md index 00a5f06..8d2e23d 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,12 @@ The app exposes the following [gRPC](https://grpc.io/) services : } ``` +- **Propagate** : propagate all configuration items using the message broker + + ```json + {} + ``` + ## Messages As mentionned earlier, RabbitMQ messages are sent after these events : @@ -117,6 +123,8 @@ As mentionned earlier, RabbitMQ messages are sent after these events : - **Delete** (message : the uuid of the deleted configuration item) +- **Propagate** (message : all the configuration items) + Various messages are also sent for logging purpose. ## Tests diff --git a/src/modules/configuration/adapters/primaries/configuration.controller.ts b/src/modules/configuration/adapters/primaries/configuration.controller.ts index a83d4c6..f3cc6a0 100644 --- a/src/modules/configuration/adapters/primaries/configuration.controller.ts +++ b/src/modules/configuration/adapters/primaries/configuration.controller.ts @@ -16,6 +16,7 @@ import { UpdateConfigurationRequest } from '../../domain/dtos/update-configurati import { Configuration } from '../../domain/entities/configuration'; import { FindAllConfigurationsQuery } from '../../queries/find-all-configurations.query'; import { FindConfigurationByUuidQuery } from '../../queries/find-configuration-by-uuid.query'; +import { PropagateConfigurationsQuery } from '../../queries/propagate-configurations.query'; import { ConfigurationPresenter } from './configuration.presenter'; @UsePipes( @@ -141,4 +142,13 @@ export class ConfigurationController { throw new RpcException({}); } } + + @GrpcMethod('ConfigurationService', 'Propagate') + async propagate(): Promise { + try { + await this._queryBus.execute(new PropagateConfigurationsQuery()); + } catch (e) { + throw new RpcException({}); + } + } } diff --git a/src/modules/configuration/adapters/primaries/configuration.proto b/src/modules/configuration/adapters/primaries/configuration.proto index 99becb3..59e0cab 100644 --- a/src/modules/configuration/adapters/primaries/configuration.proto +++ b/src/modules/configuration/adapters/primaries/configuration.proto @@ -8,6 +8,7 @@ service ConfigurationService { rpc Create(Configuration) returns (Configuration); rpc Update(Configuration) returns (Configuration); rpc Delete(ConfigurationByUuid) returns (Empty); + rpc Propagate(Empty) returns (Empty); } message ConfigurationByUuid { diff --git a/src/modules/configuration/configuration.module.ts b/src/modules/configuration/configuration.module.ts index e66f072..234ba82 100644 --- a/src/modules/configuration/configuration.module.ts +++ b/src/modules/configuration/configuration.module.ts @@ -11,6 +11,7 @@ import { CreateConfigurationUseCase } from './domain/usecases/create-configurati import { DeleteConfigurationUseCase } from './domain/usecases/delete-configuration.usecase'; import { FindAllConfigurationsUseCase } from './domain/usecases/find-all-configurations.usecase'; import { FindConfigurationByUuidUseCase } from './domain/usecases/find-configuration-by-uuid.usecase'; +import { PropagateConfigurationsUseCase } from './domain/usecases/propagate-configurations.usecase'; import { UpdateConfigurationUseCase } from './domain/usecases/update-configuration.usecase'; import { ConfigurationProfile } from './mappers/configuration.profile'; @@ -49,6 +50,7 @@ import { ConfigurationProfile } from './mappers/configuration.profile'; CreateConfigurationUseCase, UpdateConfigurationUseCase, DeleteConfigurationUseCase, + PropagateConfigurationsUseCase, ], }) export class ConfigurationModule {} diff --git a/src/modules/configuration/domain/usecases/propagate-configurations.usecase.ts b/src/modules/configuration/domain/usecases/propagate-configurations.usecase.ts new file mode 100644 index 0000000..e87b0a8 --- /dev/null +++ b/src/modules/configuration/domain/usecases/propagate-configurations.usecase.ts @@ -0,0 +1,42 @@ +import { Mapper } from '@automapper/core'; +import { InjectMapper } from '@automapper/nestjs'; +import { QueryHandler } from '@nestjs/cqrs'; +import { ConfigurationMessagerPresenter } from '../../adapters/secondaries/configuration-messager.presenter'; +import { ConfigurationMessager } from '../../adapters/secondaries/configuration.messager'; +import { ConfigurationRepository } from '../../adapters/secondaries/configuration.repository'; +import { LoggingMessager } from '../../adapters/secondaries/logging.messager'; +import { PropagateConfigurationsQuery } from '../../queries/propagate-configurations.query'; +import { Configuration } from '../entities/configuration'; + +@QueryHandler(PropagateConfigurationsQuery) +export class PropagateConfigurationsUseCase { + constructor( + private readonly _repository: ConfigurationRepository, + private readonly _configurationMessager: ConfigurationMessager, + private readonly _loggingMessager: LoggingMessager, + @InjectMapper() private readonly _mapper: Mapper, + ) {} + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async execute(propagateConfigurationsQuery: PropagateConfigurationsQuery) { + try { + const configurations = await this._repository.findAll(1, 999999); + this._configurationMessager.publish( + 'propagate', + JSON.stringify( + configurations.data.map((configuration) => + this._mapper.map( + configuration, + Configuration, + ConfigurationMessagerPresenter, + ), + ), + ), + ); + this._loggingMessager.publish('configuration.update.info', 'propagation'); + } catch (error) { + this._loggingMessager.publish('configuration.update.crit', 'propagation'); + throw error; + } + } +} diff --git a/src/modules/configuration/queries/propagate-configurations.query.ts b/src/modules/configuration/queries/propagate-configurations.query.ts new file mode 100644 index 0000000..f858729 --- /dev/null +++ b/src/modules/configuration/queries/propagate-configurations.query.ts @@ -0,0 +1 @@ +export class PropagateConfigurationsQuery {} diff --git a/src/modules/configuration/tests/unit/configuration-messager.usecase.spec.ts b/src/modules/configuration/tests/unit/configuration-messager.usecase.spec.ts new file mode 100644 index 0000000..f65baf9 --- /dev/null +++ b/src/modules/configuration/tests/unit/configuration-messager.usecase.spec.ts @@ -0,0 +1,38 @@ +import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigurationMessager } from '../../adapters/secondaries/configuration.messager'; + +const mockAmqpConnection = { + publish: jest.fn().mockImplementation(), +}; + +describe('ConfigurationMessager', () => { + let configurationMessager: ConfigurationMessager; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [], + providers: [ + ConfigurationMessager, + { + provide: AmqpConnection, + useValue: mockAmqpConnection, + }, + ], + }).compile(); + + configurationMessager = module.get( + ConfigurationMessager, + ); + }); + + it('should be defined', () => { + expect(configurationMessager).toBeDefined(); + }); + + it('should publish a message', async () => { + jest.spyOn(mockAmqpConnection, 'publish'); + await configurationMessager.publish('configuration.create.info', 'my-test'); + expect(mockAmqpConnection.publish).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/modules/configuration/tests/unit/create-configuration.usecase.spec.ts b/src/modules/configuration/tests/unit/create-configuration.usecase.spec.ts index a59e85b..d0df044 100644 --- a/src/modules/configuration/tests/unit/create-configuration.usecase.spec.ts +++ b/src/modules/configuration/tests/unit/create-configuration.usecase.spec.ts @@ -33,7 +33,11 @@ const mockConfigurationRepository = { }), }; -const mockMessager = { +const mockConfigurationMessager = { + publish: jest.fn().mockImplementation(), +}; + +const mockLoggingMessager = { publish: jest.fn().mockImplementation(), }; @@ -52,11 +56,11 @@ describe('CreateConfigurationUseCase', () => { ConfigurationProfile, { provide: ConfigurationMessager, - useValue: mockMessager, + useValue: mockConfigurationMessager, }, { provide: LoggingMessager, - useValue: mockMessager, + useValue: mockLoggingMessager, }, ], }).compile(); @@ -72,11 +76,13 @@ describe('CreateConfigurationUseCase', () => { describe('execute', () => { it('should create and return a new configuration', async () => { + jest.spyOn(mockConfigurationMessager, 'publish'); const newConfiguration: Configuration = await createConfigurationUseCase.execute(newConfigurationCommand); expect(newConfiguration.key).toBe(newConfigurationRequest.key); expect(newConfiguration.uuid).toBeDefined(); + expect(mockConfigurationMessager.publish).toHaveBeenCalledTimes(1); }); it('should throw an error if configuration already exists', async () => { diff --git a/src/modules/configuration/tests/unit/delete-configuration.usecase.spec.ts b/src/modules/configuration/tests/unit/delete-configuration.usecase.spec.ts index 072220b..215bf3a 100644 --- a/src/modules/configuration/tests/unit/delete-configuration.usecase.spec.ts +++ b/src/modules/configuration/tests/unit/delete-configuration.usecase.spec.ts @@ -1,44 +1,49 @@ import { classes } from '@automapper/classes'; import { AutomapperModule } from '@automapper/nestjs'; import { Test, TestingModule } from '@nestjs/testing'; +import { ICollection } from 'src/modules/database/src/interfaces/collection.interface'; import { ConfigurationMessager } from '../../adapters/secondaries/configuration.messager'; import { ConfigurationRepository } from '../../adapters/secondaries/configuration.repository'; import { LoggingMessager } from '../../adapters/secondaries/logging.messager'; import { DeleteConfigurationCommand } from '../../commands/delete-configuration.command'; import { Domain } from '../../domain/dtos/domain.enum'; +import { Configuration } from '../../domain/entities/configuration'; import { DeleteConfigurationUseCase } from '../../domain/usecases/delete-configuration.usecase'; import { ConfigurationProfile } from '../../mappers/configuration.profile'; -const mockConfigurations = [ - { - uuid: 'bb281075-1b98-4456-89d6-c643d3044a91', - domain: Domain.USER, - key: 'key1', - value: 'value1', - }, - { - uuid: 'bb281075-1b98-4456-89d6-c643d3044a92', - domain: Domain.USER, - key: 'key2', - value: 'value2', - }, - { - uuid: 'bb281075-1b98-4456-89d6-c643d3044a93', - domain: Domain.USER, - key: 'key3', - value: 'value3', - }, -]; +const mockConfigurations: ICollection = { + data: [ + { + uuid: 'bb281075-1b98-4456-89d6-c643d3044a91', + domain: Domain.USER, + key: 'key1', + value: 'value1', + }, + { + uuid: 'bb281075-1b98-4456-89d6-c643d3044a92', + domain: Domain.USER, + key: 'key2', + value: 'value2', + }, + { + uuid: 'bb281075-1b98-4456-89d6-c643d3044a93', + domain: Domain.USER, + key: 'key3', + value: 'value3', + }, + ], + total: 3, +}; const mockConfigurationRepository = { delete: jest .fn() .mockImplementationOnce((uuid: string) => { let savedConfiguration = {}; - mockConfigurations.forEach((configuration, index) => { + mockConfigurations.data.forEach((configuration, index) => { if (configuration.uuid === uuid) { savedConfiguration = { ...configuration }; - mockConfigurations.splice(index, 1); + mockConfigurations.data.splice(index, 1); } }); return savedConfiguration; @@ -48,10 +53,13 @@ const mockConfigurationRepository = { }), }; -const mockMessager = { +const mockConfigurationMessager = { publish: jest.fn().mockImplementation(), }; +const mockLoggingMessager = { + publish: jest.fn().mockImplementation(), +}; describe('DeleteConfigurationUseCase', () => { let deleteConfigurationUseCase: DeleteConfigurationUseCase; @@ -67,11 +75,11 @@ describe('DeleteConfigurationUseCase', () => { ConfigurationProfile, { provide: ConfigurationMessager, - useValue: mockMessager, + useValue: mockConfigurationMessager, }, { provide: LoggingMessager, - useValue: mockMessager, + useValue: mockLoggingMessager, }, ], }).compile(); @@ -87,16 +95,18 @@ describe('DeleteConfigurationUseCase', () => { describe('execute', () => { it('should delete a configuration', async () => { - const savedUuid = mockConfigurations[0].uuid; + jest.spyOn(mockConfigurationMessager, 'publish'); + const savedUuid = mockConfigurations.data[0].uuid; const deleteConfigurationCommand = new DeleteConfigurationCommand( savedUuid, ); await deleteConfigurationUseCase.execute(deleteConfigurationCommand); - const deletedConfiguration = mockConfigurations.find( + const deletedConfiguration = mockConfigurations.data.find( (configuration) => configuration.uuid === savedUuid, ); expect(deletedConfiguration).toBeUndefined(); + expect(mockConfigurationMessager.publish).toHaveBeenCalledTimes(1); }); it('should throw an error if configuration does not exist', async () => { await expect( diff --git a/src/modules/configuration/tests/unit/find-all-configurations.usecase.spec.ts b/src/modules/configuration/tests/unit/find-all-configurations.usecase.spec.ts index 2ddd52f..4a6e0f8 100644 --- a/src/modules/configuration/tests/unit/find-all-configurations.usecase.spec.ts +++ b/src/modules/configuration/tests/unit/find-all-configurations.usecase.spec.ts @@ -1,7 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { ICollection } from 'src/modules/database/src/interfaces/collection.interface'; import { ConfigurationRepository } from '../../adapters/secondaries/configuration.repository'; import { Domain } from '../../domain/dtos/domain.enum'; import { FindAllConfigurationsRequest } from '../../domain/dtos/find-all-configurations.request'; +import { Configuration } from '../../domain/entities/configuration'; import { FindAllConfigurationsUseCase } from '../../domain/usecases/find-all-configurations.usecase'; import { FindAllConfigurationsQuery } from '../../queries/find-all-configurations.query'; @@ -13,26 +15,29 @@ findAllConfigurationsRequest.perPage = 10; const findAllConfigurationsQuery: FindAllConfigurationsQuery = new FindAllConfigurationsQuery(findAllConfigurationsRequest); -const mockConfigurations = [ - { - uuid: 'bb281075-1b98-4456-89d6-c643d3044a91', - domain: Domain.USER, - key: 'key1', - value: 'value1', - }, - { - uuid: 'bb281075-1b98-4456-89d6-c643d3044a92', - domain: Domain.USER, - key: 'key2', - value: 'value2', - }, - { - uuid: 'bb281075-1b98-4456-89d6-c643d3044a93', - domain: Domain.USER, - key: 'key3', - value: 'value3', - }, -]; +const mockConfigurations: ICollection = { + data: [ + { + uuid: 'bb281075-1b98-4456-89d6-c643d3044a91', + domain: Domain.USER, + key: 'key1', + value: 'value1', + }, + { + uuid: 'bb281075-1b98-4456-89d6-c643d3044a92', + domain: Domain.USER, + key: 'key2', + value: 'value2', + }, + { + uuid: 'bb281075-1b98-4456-89d6-c643d3044a93', + domain: Domain.USER, + key: 'key3', + value: 'value3', + }, + ], + total: 3, +}; const mockConfigurationRepository = { findAll: jest diff --git a/src/modules/configuration/tests/unit/logging-messager.usecase.spec.ts b/src/modules/configuration/tests/unit/logging-messager.usecase.spec.ts new file mode 100644 index 0000000..b7f084f --- /dev/null +++ b/src/modules/configuration/tests/unit/logging-messager.usecase.spec.ts @@ -0,0 +1,36 @@ +import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'; +import { Test, TestingModule } from '@nestjs/testing'; +import { LoggingMessager } from '../../adapters/secondaries/logging.messager'; + +const mockAmqpConnection = { + publish: jest.fn().mockImplementation(), +}; + +describe('LoggingMessager', () => { + let loggingMessager: LoggingMessager; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [], + providers: [ + LoggingMessager, + { + provide: AmqpConnection, + useValue: mockAmqpConnection, + }, + ], + }).compile(); + + loggingMessager = module.get(LoggingMessager); + }); + + it('should be defined', () => { + expect(LoggingMessager).toBeDefined(); + }); + + it('should publish a message', async () => { + jest.spyOn(mockAmqpConnection, 'publish'); + await loggingMessager.publish('configuration.create.info', 'my-test'); + expect(mockAmqpConnection.publish).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/modules/configuration/tests/unit/propagate-configurations.usecase.spec.ts b/src/modules/configuration/tests/unit/propagate-configurations.usecase.spec.ts new file mode 100644 index 0000000..7ebcec3 --- /dev/null +++ b/src/modules/configuration/tests/unit/propagate-configurations.usecase.spec.ts @@ -0,0 +1,109 @@ +import { classes } from '@automapper/classes'; +import { AutomapperModule } from '@automapper/nestjs'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ICollection } from 'src/modules/database/src/interfaces/collection.interface'; +import { ConfigurationMessager } from '../../adapters/secondaries/configuration.messager'; +import { ConfigurationRepository } from '../../adapters/secondaries/configuration.repository'; +import { LoggingMessager } from '../../adapters/secondaries/logging.messager'; +import { Domain } from '../../domain/dtos/domain.enum'; +import { Configuration } from '../../domain/entities/configuration'; +import { PropagateConfigurationsUseCase } from '../../domain/usecases/propagate-configurations.usecase'; +import { ConfigurationProfile } from '../../mappers/configuration.profile'; +import { PropagateConfigurationsQuery } from '../../queries/propagate-configurations.query'; + +const propagateConfigurationsQuery: PropagateConfigurationsQuery = + new PropagateConfigurationsQuery(); + +const mockConfigurations: ICollection = { + data: [ + { + uuid: 'bb281075-1b98-4456-89d6-c643d3044a91', + domain: Domain.USER, + key: 'key1', + value: 'value1', + }, + { + uuid: 'bb281075-1b98-4456-89d6-c643d3044a92', + domain: Domain.USER, + key: 'key2', + value: 'value2', + }, + { + uuid: 'bb281075-1b98-4456-89d6-c643d3044a93', + domain: Domain.USER, + key: 'key3', + value: 'value3', + }, + ], + total: 3, +}; + +const mockConfigurationRepository = { + findAll: jest + .fn() + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .mockImplementationOnce((query?: PropagateConfigurationsQuery) => { + return Promise.resolve(mockConfigurations); + }) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .mockImplementationOnce((query?: PropagateConfigurationsQuery) => { + throw new Error(); + }), +}; + +const mockConfigurationMessager = { + publish: jest.fn().mockImplementation(), +}; + +const mockLoggingMessager = { + publish: jest.fn().mockImplementation(), +}; + +describe('PropagateConfigurationsUseCase', () => { + let propagateConfigurationsUseCase: PropagateConfigurationsUseCase; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })], + providers: [ + { + provide: ConfigurationRepository, + useValue: mockConfigurationRepository, + }, + { + provide: ConfigurationMessager, + useValue: mockConfigurationMessager, + }, + { + provide: LoggingMessager, + useValue: mockLoggingMessager, + }, + PropagateConfigurationsUseCase, + ConfigurationProfile, + ], + }).compile(); + + propagateConfigurationsUseCase = module.get( + PropagateConfigurationsUseCase, + ); + }); + + it('should be defined', () => { + expect(propagateConfigurationsUseCase).toBeDefined(); + }); + + describe('execute', () => { + it('should propagate configurations', async () => { + jest.spyOn(mockConfigurationMessager, 'publish'); + await propagateConfigurationsUseCase.execute( + propagateConfigurationsQuery, + ); + expect(mockConfigurationMessager.publish).toHaveBeenCalledTimes(1); + }); + it('should throw an error if repository call fails', async () => { + await expect( + propagateConfigurationsUseCase.execute(propagateConfigurationsQuery), + ).rejects.toBeInstanceOf(Error); + }); + }); +}); diff --git a/src/modules/configuration/tests/unit/update-configuration.usecase.spec.ts b/src/modules/configuration/tests/unit/update-configuration.usecase.spec.ts index 7d022f1..d530ad7 100644 --- a/src/modules/configuration/tests/unit/update-configuration.usecase.spec.ts +++ b/src/modules/configuration/tests/unit/update-configuration.usecase.spec.ts @@ -38,7 +38,11 @@ const mockConfigurationRepository = { }), }; -const mockMessager = { +const mockConfigurationMessager = { + publish: jest.fn().mockImplementation(), +}; + +const mockLoggingMessager = { publish: jest.fn().mockImplementation(), }; @@ -57,11 +61,11 @@ describe('UpdateConfigurationUseCase', () => { ConfigurationProfile, { provide: ConfigurationMessager, - useValue: mockMessager, + useValue: mockConfigurationMessager, }, { provide: LoggingMessager, - useValue: mockMessager, + useValue: mockLoggingMessager, }, ], }).compile(); @@ -77,10 +81,12 @@ describe('UpdateConfigurationUseCase', () => { describe('execute', () => { it('should update a configuration value', async () => { + jest.spyOn(mockConfigurationMessager, 'publish'); const updatedConfiguration: Configuration = await updateConfigurationUseCase.execute(updateConfigurationCommand); expect(updatedConfiguration.value).toBe(updateConfigurationRequest.value); + expect(mockConfigurationMessager.publish).toHaveBeenCalledTimes(1); }); it('should throw an error if configuration does not exist', async () => { await expect( diff --git a/src/utils/tests/unit/rpc-validation-pipe.usecase.spec.ts b/src/utils/tests/unit/rpc-validation-pipe.usecase.spec.ts new file mode 100644 index 0000000..32c9de3 --- /dev/null +++ b/src/utils/tests/unit/rpc-validation-pipe.usecase.spec.ts @@ -0,0 +1,22 @@ +import { ArgumentMetadata } from '@nestjs/common'; +import { FindConfigurationByUuidRequest } from '../../../modules/configuration/domain/dtos/find-configuration-by-uuid.request'; +import { RpcValidationPipe } from '../../pipes/rpc.validation-pipe'; + +describe('RpcValidationPipe', () => { + it('should not validate request', async () => { + const target: RpcValidationPipe = new RpcValidationPipe({ + whitelist: true, + forbidUnknownValues: false, + }); + const metadata: ArgumentMetadata = { + type: 'body', + metatype: FindConfigurationByUuidRequest, + data: '', + }; + await target + .transform({}, metadata) + .catch((err) => { + expect(err.message).toEqual('Rpc Exception'); + }); + }); +});