Merge branch 'refactor' into 'main'

Refactor to better hexagon

See merge request v3/service/configuration!24
This commit is contained in:
Sylvain Briat 2023-10-20 10:23:00 +00:00
commit 376260d903
108 changed files with 5083 additions and 11832 deletions

View File

@ -6,6 +6,7 @@ HEALTH_SERVICE_PORT=6003
# PRISMA
DATABASE_URL="postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=configuration"
# RABBIT MQ
RMQ_URI=amqp://v3-broker:5672
RMQ_EXCHANGE=mobicoop
# MESSAGE BROKER
MESSAGE_BROKER_URI=amqp://v3-broker:5672
MESSAGE_BROKER_EXCHANGE=mobicoop
MESSAGE_BROKER_EXCHANGE_DURABILITY=true

View File

@ -2,6 +2,8 @@
Configuration items management. Used to configure all services using a broker to disseminate the configuration items.
This service handles the persistence of the configuration items of all services in a database, and sends values _via_ the broker.
Each item consists in :
- a **uuid** : a unique identifier for the configuration item
@ -15,7 +17,9 @@ Practically, it's the other way round as it's easier to use this configuration s
## Available domains
- **USER** : user related configuration item
- **AD** : ad related configuration items
- **MATCHER** : matching algotithm related configuration items
- **USER** : user related configuration items
## Requirements
@ -63,24 +67,16 @@ npm run migrate
The app exposes the following [gRPC](https://grpc.io/) services :
- **FindByUuid** : find a configuration item by its uuid
- **Get** : get a configuration item by its domain and key
```json
{
"uuid": "80126a61-d128-4f96-afdb-92e33c75a3e1"
"domain": "AD",
"key": "seatsProposed"
}
```
- **FindAll** : find all configuration items; you can use pagination with `page` (default:_1_) and `perPage` (default:_10_)
```json
{
"page": 1,
"perPage": 10
}
```
- **Create** : create a configuration item (note that uuid is optional, a uuid will be automatically attributed if it is not provided)
- **Set** : create or update a configuration item
```json
{
@ -90,20 +86,12 @@ The app exposes the following [gRPC](https://grpc.io/) services :
}
```
- **Update** : update a configuration item value
- **Delete** : delete a configuration item by its domain and key
```json
{
"value": "value2",
"uuid": "30f49838-3f24-42bb-a489-8ffb480173ae"
}
```
- **Delete** : delete a configuration item by its uuid
```json
{
"uuid": "80126a61-d128-4f96-afdb-92e33c75a3e1"
"domain": "AD",
"key": "seatsProposed"
}
```
@ -117,9 +105,7 @@ The app exposes the following [gRPC](https://grpc.io/) services :
As mentionned earlier, RabbitMQ messages are sent after these events :
- **Create** (message : the created configuration item informations)
- **Update** (message : the updated configuration item informations)
- **Set** (message : the created / updated configuration item informations)
- **Delete** (message : the uuid of the deleted configuration item)

12087
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "@mobicoop/configuration",
"version": "0.0.1",
"version": "1.0.0",
"description": "Mobicoop V3 Configuration Service",
"author": "sbriat",
"private": true,
@ -31,50 +31,52 @@
"migrate:deploy": "npx prisma migrate deploy"
},
"dependencies": {
"@automapper/classes": "^8.7.7",
"@automapper/core": "^8.7.7",
"@automapper/nestjs": "^8.7.7",
"@golevelup/nestjs-rabbitmq": "^3.4.0",
"@grpc/grpc-js": "^1.8.5",
"@grpc/proto-loader": "^0.7.4",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.0.0",
"@nestjs/cqrs": "^9.0.1",
"@nestjs/microservices": "^9.2.1",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/terminus": "^9.2.2",
"@prisma/client": "^4.9.0",
"@grpc/grpc-js": "^1.9.6",
"@grpc/proto-loader": "^0.7.10",
"@mobicoop/ddd-library": "^2.1.1",
"@mobicoop/health-module": "^2.3.1",
"@mobicoop/message-broker-module": "^2.1.1",
"@nestjs/common": "^10.2.7",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.2.7",
"@nestjs/cqrs": "^10.2.6",
"@nestjs/event-emitter": "^2.0.2",
"@nestjs/microservices": "^10.2.7",
"@nestjs/platform-express": "^10.2.7",
"@nestjs/terminus": "^10.1.1",
"@prisma/client": "^5.4.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0"
"rimraf": "^5.0.5",
"rxjs": "^7.8.1",
"uuid": "^9.0.1"
},
"devDependencies": {
"@nestjs/cli": "^9.0.0",
"@nestjs/schematics": "^9.0.0",
"@nestjs/testing": "^9.0.0",
"@types/express": "^4.17.13",
"@types/jest": "28.1.8",
"@types/node": "^16.0.0",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"dotenv-cli": "^7.0.0",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"jest": "28.1.3",
"prettier": "^2.3.2",
"prisma": "^4.9.0",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",
"ts-jest": "28.0.8",
"ts-loader": "^9.2.3",
"ts-node": "^10.0.0",
"tsconfig-paths": "4.1.0",
"typescript": "^4.7.4"
"@nestjs/cli": "^10.1.18",
"@nestjs/schematics": "^10.0.2",
"@nestjs/testing": "^10.2.7",
"@types/express": "^4.17.20",
"@types/jest": "29.5.6",
"@types/node": "^20.8.7",
"@types/supertest": "^2.0.15",
"@types/uuid": "^9.0.6",
"@typescript-eslint/eslint-plugin": "^6.8.0",
"@typescript-eslint/parser": "^6.8.0",
"dotenv-cli": "^7.3.0",
"eslint": "^8.51.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.1",
"jest": "29.7.0",
"prettier": "^3.0.3",
"prisma": "^5.4.2",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "29.1.1",
"ts-loader": "^9.5.0",
"ts-node": "^10.9.1",
"tsconfig-paths": "4.2.0",
"typescript": "^5.2.2"
},
"jest": {
"moduleFileExtensions": [
@ -83,12 +85,13 @@
"ts"
],
"modulePathIgnorePatterns": [
".controller.ts",
".constants.ts",
".module.ts",
".request.ts",
".presenter.ts",
".profile.ts",
".exception.ts",
".dto.ts",
".di-tokens.ts",
".response.ts",
".port.ts",
"prisma.service.ts",
"main.ts"
],
"rootDir": "src",
@ -100,15 +103,20 @@
"**/*.(t|j)s"
],
"coveragePathIgnorePatterns": [
".controller.ts",
".constants.ts",
".module.ts",
".request.ts",
".presenter.ts",
".profile.ts",
".exception.ts",
".dto.ts",
".di-tokens.ts",
".response.ts",
".port.ts",
"prisma.service.ts",
"main.ts"
],
"coverageDirectory": "../coverage",
"moduleNameMapper": {
"^@modules(.*)": "<rootDir>/modules/$1",
"^@src(.*)": "<rootDir>$1"
},
"testEnvironment": "node"
}
}

View File

@ -3,6 +3,7 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["linux-musl", "debian-openssl-3.0.x"]
}
datasource db {

16
src/app.constants.ts Normal file
View File

@ -0,0 +1,16 @@
// service
export const SERVICE_NAME = 'configuration';
// grpc
export const GRPC_PACKAGE_NAME = 'configuration';
export const GRPC_SERVICE_NAME = 'ConfigurationService';
// messaging
export const CONFIGURATION_SET_ROUTING_KEY = 'configuration.set';
export const CONFIGURATION_DELETED_ROUTING_KEY = 'configuration.deleted';
export const CONFIGURATION_PROPAGATED_ROUTING_KEY = 'configuration.propagated';
// health
export const GRPC_HEALTH_PACKAGE_NAME = 'health';
export const HEALTH_CONFIGURATION_REPOSITORY = 'ConfigurationRepository';
export const HEALTH_CRITICAL_LOGGING_KEY = 'logging.configuration.health.crit';

View File

@ -1,18 +1,47 @@
import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs';
import {
HealthModule,
HealthModuleOptions,
HealthRepositoryPort,
} from '@mobicoop/health-module';
import { MessagerModule } from '@modules/messager/messager.module';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ConfigurationModule } from './modules/configuration/configuration.module';
import { HealthModule } from './modules/health/health.module';
import {
HEALTH_CONFIGURATION_REPOSITORY,
HEALTH_CRITICAL_LOGGING_KEY,
SERVICE_NAME,
} from './app.constants';
import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { MESSAGE_PUBLISHER } from '@modules/messager/messager.di-tokens';
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens';
import { ConfigurationModule } from '@modules/configuration/configuration.module';
import { EventEmitterModule } from '@nestjs/event-emitter';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
AutomapperModule.forRoot({ strategyInitializer: classes() }),
ConfigurationModule,
HealthModule,
EventEmitterModule.forRoot(),
HealthModule.forRootAsync({
imports: [ConfigurationModule, MessagerModule],
inject: [CONFIGURATION_REPOSITORY, MESSAGE_PUBLISHER],
useFactory: async (
configurationRepository: HealthRepositoryPort,
messagePublisher: MessagePublisherPort,
): Promise<HealthModuleOptions> => ({
serviceName: SERVICE_NAME,
criticalLoggingKey: HEALTH_CRITICAL_LOGGING_KEY,
checkRepositories: [
{
name: HEALTH_CONFIGURATION_REPOSITORY,
repository: configurationRepository,
},
],
controllers: [],
providers: [],
messagePublisher,
}),
}),
ConfigurationModule,
MessagerModule,
],
exports: [ConfigurationModule, MessagerModule],
})
export class AppModule {}

View File

@ -2,7 +2,6 @@ syntax = "proto3";
package health;
service Health {
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
}
@ -18,4 +17,5 @@ message HealthCheckResponse {
NOT_SERVING = 2;
}
ServingStatus status = 1;
string message = 2;
}

View File

@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { join } from 'path';
import { AppModule } from './app.module';
import { GRPC_HEALTH_PACKAGE_NAME, GRPC_PACKAGE_NAME } from './app.constants';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
@ -11,20 +12,20 @@ async function bootstrap() {
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.GRPC,
options: {
package: ['configuration', 'health'],
package: [GRPC_PACKAGE_NAME, GRPC_HEALTH_PACKAGE_NAME],
protoPath: [
join(
__dirname,
'modules/configuration/adapters/primaries/configuration.proto',
'modules/configuration/interface/grpc-controllers/configuration.proto',
),
join(__dirname, 'modules/health/adapters/primaries/health.proto'),
join(__dirname, 'health.proto'),
],
url: process.env.SERVICE_URL + ':' + process.env.SERVICE_PORT,
loader: { keepCase: true },
url: `${process.env.SERVICE_URL}:${process.env.SERVICE_PORT}`,
loader: { keepCase: true, enums: String },
},
});
await app.startAllMicroservices();
await app.listen(process.env.HEALTH_SERVICE_PORT);
await app.listen(process.env.HEALTH_SERVICE_PORT as unknown as number);
}
bootstrap();

View File

@ -1,154 +0,0 @@
import { Mapper } from '@automapper/core';
import { InjectMapper } from '@automapper/nestjs';
import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { DatabaseException } from 'src/modules/database/exceptions/database.exception';
import { ICollection } from 'src/modules/database/interfaces/collection.interface';
import { RpcValidationPipe } from 'src/utils/pipes/rpc.validation-pipe';
import { CreateConfigurationCommand } from '../../commands/create-configuration.command';
import { DeleteConfigurationCommand } from '../../commands/delete-configuration.command';
import { UpdateConfigurationCommand } from '../../commands/update-configuration.command';
import { CreateConfigurationRequest } from '../../domain/dtos/create-configuration.request';
import { FindAllConfigurationsRequest } from '../../domain/dtos/find-all-configurations.request';
import { FindConfigurationByUuidRequest } from '../../domain/dtos/find-configuration-by-uuid.request';
import { UpdateConfigurationRequest } from '../../domain/dtos/update-configuration.request';
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(
new RpcValidationPipe({
whitelist: true,
forbidUnknownValues: false,
}),
)
@Controller()
export class ConfigurationController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
@InjectMapper() private readonly mapper: Mapper,
) {}
@GrpcMethod('ConfigurationService', 'FindAll')
async findAll(
data: FindAllConfigurationsRequest,
): Promise<ICollection<Configuration>> {
const configurationCollection = await this.queryBus.execute(
new FindAllConfigurationsQuery(data),
);
return Promise.resolve({
data: configurationCollection.data.map((configuration: Configuration) =>
this.mapper.map(configuration, Configuration, ConfigurationPresenter),
),
total: configurationCollection.total,
});
}
@GrpcMethod('ConfigurationService', 'FindOneByUuid')
async findOneByUuid(
data: FindConfigurationByUuidRequest,
): Promise<ConfigurationPresenter> {
try {
const configuration = await this.queryBus.execute(
new FindConfigurationByUuidQuery(data),
);
return this.mapper.map(
configuration,
Configuration,
ConfigurationPresenter,
);
} catch (error) {
throw new RpcException({
code: 5,
message: 'Configuration not found',
});
}
}
@GrpcMethod('ConfigurationService', 'Create')
async createConfiguration(
data: CreateConfigurationRequest,
): Promise<ConfigurationPresenter> {
try {
const configuration = await this.commandBus.execute(
new CreateConfigurationCommand(data),
);
return this.mapper.map(
configuration,
Configuration,
ConfigurationPresenter,
);
} catch (e) {
if (e instanceof DatabaseException) {
if (e.message.includes('Already exists')) {
throw new RpcException({
code: 6,
message: 'Configuration already exists',
});
}
}
throw new RpcException({});
}
}
@GrpcMethod('ConfigurationService', 'Update')
async updateConfiguration(
data: UpdateConfigurationRequest,
): Promise<ConfigurationPresenter> {
try {
const configuration = await this.commandBus.execute(
new UpdateConfigurationCommand(data),
);
return this.mapper.map(
configuration,
Configuration,
ConfigurationPresenter,
);
} catch (e) {
if (e instanceof DatabaseException) {
if (e.message.includes('not found')) {
throw new RpcException({
code: 5,
message: 'Configuration not found',
});
}
}
throw new RpcException({});
}
}
@GrpcMethod('ConfigurationService', 'Delete')
async deleteConfiguration(
data: FindConfigurationByUuidRequest,
): Promise<void> {
try {
await this.commandBus.execute(new DeleteConfigurationCommand(data.uuid));
return Promise.resolve();
} catch (e) {
if (e instanceof DatabaseException) {
if (e.message.includes('not found')) {
throw new RpcException({
code: 5,
message: 'Configuration not found',
});
}
}
throw new RpcException({});
}
}
@GrpcMethod('ConfigurationService', 'Propagate')
async propagate(): Promise<void> {
try {
await this.queryBus.execute(new PropagateConfigurationsQuery());
} catch (e) {
throw new RpcException({});
}
}
}

View File

@ -1,16 +0,0 @@
import { AutoMap } from '@automapper/classes';
import { Domain } from '../../domain/dtos/domain.enum';
export class ConfigurationPresenter {
@AutoMap()
uuid: string;
@AutoMap()
domain: Domain;
@AutoMap()
key: string;
@AutoMap()
value: string;
}

View File

@ -1,35 +0,0 @@
syntax = "proto3";
package configuration;
service ConfigurationService {
rpc FindOneByUuid(ConfigurationByUuid) returns (Configuration);
rpc FindAll(ConfigurationFilter) returns (Configurations);
rpc Create(Configuration) returns (Configuration);
rpc Update(Configuration) returns (Configuration);
rpc Delete(ConfigurationByUuid) returns (Empty);
rpc Propagate(Empty) returns (Empty);
}
message ConfigurationByUuid {
string uuid = 1;
}
message Configuration {
string uuid = 1;
string domain = 2;
string key = 3;
string value = 4;
}
message ConfigurationFilter {
optional int32 page = 1;
optional int32 perPage = 2;
}
message Configurations {
repeated Configuration data = 1;
int32 total = 2;
}
message Empty {}

View File

@ -1,13 +0,0 @@
import { AutoMap } from '@automapper/classes';
import { Domain } from '../../domain/dtos/domain.enum';
export class ConfigurationMessagerPresenter {
@AutoMap()
domain: Domain;
@AutoMap()
key: string;
@AutoMap()
value: string;
}

View File

@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ConfigRepository } from '../../../database/domain/configuration.repository';
import { Configuration } from '../../domain/entities/configuration';
@Injectable()
export class ConfigurationRepository extends ConfigRepository<Configuration> {
protected model = 'configuration';
}

View File

@ -1,18 +0,0 @@
import { AmqpConnection } from '@golevelup/nestjs-rabbitmq';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IMessageBroker } from '../../domain/interfaces/message-broker';
@Injectable()
export class Messager extends IMessageBroker {
constructor(
private readonly amqpConnection: AmqpConnection,
private readonly configService: ConfigService,
) {
super(configService.get<string>('RMQ_EXCHANGE'));
}
publish = (routingKey: string, message: string): void => {
this.amqpConnection.publish(this.exchange, routingKey, message);
};
}

View File

@ -1,9 +0,0 @@
import { CreateConfigurationRequest } from '../domain/dtos/create-configuration.request';
export class CreateConfigurationCommand {
readonly createConfigurationRequest: CreateConfigurationRequest;
constructor(request: CreateConfigurationRequest) {
this.createConfigurationRequest = request;
}
}

View File

@ -1,7 +0,0 @@
export class DeleteConfigurationCommand {
readonly uuid: string;
constructor(uuid: string) {
this.uuid = uuid;
}
}

View File

@ -1,9 +0,0 @@
import { UpdateConfigurationRequest } from '../domain/dtos/update-configuration.request';
export class UpdateConfigurationCommand {
readonly updateConfigurationRequest: UpdateConfigurationRequest;
constructor(request: UpdateConfigurationRequest) {
this.updateConfigurationRequest = request;
}
}

View File

@ -0,0 +1,4 @@
export const CONFIGURATION_MESSAGE_PUBLISHER = Symbol(
'CONFIGURATION_MESSAGE_PUBLISHER',
);
export const CONFIGURATION_REPOSITORY = Symbol('CONFIGURATION_REPOSITORY');

View File

@ -0,0 +1,60 @@
import { Mapper } from '@mobicoop/ddd-library';
import { Injectable } from '@nestjs/common';
import { ConfigurationEntity } from './core/domain/configuration.entity';
import {
ConfigurationReadModel,
ConfigurationWriteModel,
} from './infrastructure/configuration.repository';
import { ConfigurationResponseDto } from './interface/dtos/configuration.response.dto';
/**
* Mapper constructs objects that are used in different layers:
* Record is an object that is stored in a database,
* Entity is an object that is used in application domain layer,
* and a ResponseDTO is an object returned to a user (usually as json).
*/
@Injectable()
export class ConfigurationMapper
implements
Mapper<
ConfigurationEntity,
ConfigurationReadModel,
ConfigurationWriteModel,
ConfigurationResponseDto
>
{
toPersistence = (entity: ConfigurationEntity): ConfigurationWriteModel => {
const copy = entity.getProps();
const record: ConfigurationWriteModel = {
uuid: entity.id,
domain: copy.domain,
key: copy.key,
value: copy.value,
};
return record;
};
toDomain = (record: ConfigurationReadModel): ConfigurationEntity => {
const entity = new ConfigurationEntity({
id: record.uuid,
createdAt: new Date(record.createdAt),
updatedAt: new Date(record.updatedAt),
props: {
domain: record.domain,
key: record.key,
value: record.value,
},
});
return entity;
};
toResponse = (entity: ConfigurationEntity): ConfigurationResponseDto => {
const props = entity.getProps();
const response = new ConfigurationResponseDto(entity);
response.domain = props.domain;
response.key = props.key;
response.value = props.value;
return response;
};
}

View File

@ -1,50 +1,73 @@
import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Module, Provider } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { DatabaseModule } from '../database/database.module';
import { ConfigurationController } from './adapters/primaries/configuration.controller';
import { Messager } from './adapters/secondaries/messager';
import { ConfigurationRepository } from './adapters/secondaries/configuration.repository';
import { CreateConfigurationUseCase } from './domain/usecases/create-configuration.usecase';
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';
import { GetConfigurationGrpcController } from './interface/grpc-controllers/get-configuration.grpc.controller';
import { SetConfigurationGrpcController } from './interface/grpc-controllers/set-configuration.grpc.controller';
import { DeleteConfigurationGrpcController } from './interface/grpc-controllers/delete-configuration.grpc.controller';
import { PropagateConfigurationsGrpcController } from './interface/grpc-controllers/propagate-configurations.grpc.controller';
import { PublishMessageWhenConfigurationIsSetDomainEventHandler } from './core/application/event-handlers/publish-message-when-configuration-is-set.domain-event-handler';
import { PublishMessageWhenConfigurationIsDeletedDomainEventHandler } from './core/application/event-handlers/publish-message-when-configuration-is-deleted.domain-event-handler';
import { SetConfigurationService } from './core/application/commands/set-configuration/set-configuration.service';
import { DeleteConfigurationService } from './core/application/commands/delete-configuration/delete-configuration.service';
import { GetConfigurationQueryHandler } from './core/application/queries/get-configuration/get-configuration.query-handler';
import { ConfigurationMapper } from './configuration.mapper';
import {
CONFIGURATION_MESSAGE_PUBLISHER,
CONFIGURATION_REPOSITORY,
} from './configuration.di-tokens';
import { ConfigurationRepository } from './infrastructure/configuration.repository';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
import { PrismaService } from './infrastructure/prisma.service';
import { PropagateConfigurationsService } from './core/application/commands/propagate-configurations/propagate-configurations.service';
const grpcControllers = [
GetConfigurationGrpcController,
SetConfigurationGrpcController,
DeleteConfigurationGrpcController,
PropagateConfigurationsGrpcController,
];
const eventHandlers: Provider[] = [
PublishMessageWhenConfigurationIsSetDomainEventHandler,
PublishMessageWhenConfigurationIsDeletedDomainEventHandler,
];
const commandHandlers: Provider[] = [
SetConfigurationService,
DeleteConfigurationService,
PropagateConfigurationsService,
];
const queryHandlers: Provider[] = [GetConfigurationQueryHandler];
const mappers: Provider[] = [ConfigurationMapper];
const repositories: Provider[] = [
{
provide: CONFIGURATION_REPOSITORY,
useClass: ConfigurationRepository,
},
];
const messagePublishers: Provider[] = [
{
provide: CONFIGURATION_MESSAGE_PUBLISHER,
useExisting: MessageBrokerPublisher,
},
];
const orms: Provider[] = [PrismaService];
@Module({
imports: [
DatabaseModule,
CqrsModule,
RabbitMQModule.forRootAsync(RabbitMQModule, {
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
exchanges: [
{
name: configService.get<string>('RMQ_EXCHANGE'),
type: 'topic',
},
],
uri: configService.get<string>('RMQ_URI'),
connectionInitOptions: { wait: false },
}),
}),
],
exports: [],
controllers: [ConfigurationController],
imports: [CqrsModule],
controllers: [...grpcControllers],
providers: [
ConfigurationProfile,
ConfigurationRepository,
Messager,
FindAllConfigurationsUseCase,
FindConfigurationByUuidUseCase,
CreateConfigurationUseCase,
UpdateConfigurationUseCase,
DeleteConfigurationUseCase,
PropagateConfigurationsUseCase,
...eventHandlers,
...commandHandlers,
...queryHandlers,
...mappers,
...repositories,
...messagePublishers,
...orms,
],
exports: [PrismaService, ConfigurationMapper, CONFIGURATION_REPOSITORY],
})
export class ConfigurationModule {}

View File

@ -0,0 +1,12 @@
import { Command, CommandProps } from '@mobicoop/ddd-library';
export class DeleteConfigurationCommand extends Command {
readonly domain: string;
readonly key: string;
constructor(props: CommandProps<DeleteConfigurationCommand>) {
super(props);
this.domain = props.domain;
this.key = props.key;
}
}

View File

@ -0,0 +1,26 @@
import { Inject } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { DeleteConfigurationCommand } from './delete-configuration.command';
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens';
import { ConfigurationRepositoryPort } from '../../ports/configuration.repository.port';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
@CommandHandler(DeleteConfigurationCommand)
export class DeleteConfigurationService implements ICommandHandler {
constructor(
@Inject(CONFIGURATION_REPOSITORY)
private readonly configurationRepository: ConfigurationRepositoryPort,
) {}
async execute(command: DeleteConfigurationCommand): Promise<boolean> {
const configuration: ConfigurationEntity =
await this.configurationRepository.findOne({
domain: command.domain,
key: command.key,
});
configuration.delete();
const isDeleted: boolean =
await this.configurationRepository.delete(configuration);
return isDeleted;
}
}

View File

@ -0,0 +1,7 @@
import { Command, CommandProps } from '@mobicoop/ddd-library';
export class PropagateConfigurationsCommand extends Command {
constructor(props: CommandProps<PropagateConfigurationsCommand>) {
super(props);
}
}

View File

@ -0,0 +1,36 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import {
CONFIGURATION_MESSAGE_PUBLISHER,
CONFIGURATION_REPOSITORY,
} from '@modules/configuration/configuration.di-tokens';
import { ConfigurationRepositoryPort } from '../../ports/configuration.repository.port';
import { PropagateConfigurationsCommand } from './propagate-configurations.command';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { CONFIGURATION_PROPAGATED_ROUTING_KEY } from '@src/app.constants';
import { ConfigurationMapper } from '@modules/configuration/configuration.mapper';
@CommandHandler(PropagateConfigurationsCommand)
export class PropagateConfigurationsService implements ICommandHandler {
constructor(
@Inject(CONFIGURATION_REPOSITORY)
private readonly repository: ConfigurationRepositoryPort,
@Inject(CONFIGURATION_MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
private readonly configurationMapper: ConfigurationMapper,
) {}
async execute(): Promise<void> {
const configurationItems: ConfigurationEntity[] =
await this.repository.findAll({});
this.messagePublisher.publish(
CONFIGURATION_PROPAGATED_ROUTING_KEY,
JSON.stringify(
configurationItems.map((configuration: ConfigurationEntity) =>
this.configurationMapper.toResponse(configuration),
),
),
);
}
}

View File

@ -0,0 +1,14 @@
import { Command, CommandProps } from '@mobicoop/ddd-library';
export class SetConfigurationCommand extends Command {
readonly domain: string;
readonly key: string;
readonly value: string;
constructor(props: CommandProps<SetConfigurationCommand>) {
super(props);
this.domain = props.domain;
this.key = props.key;
this.value = props.value;
}
}

View File

@ -0,0 +1,42 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { AggregateID, NotFoundException } from '@mobicoop/ddd-library';
import { SetConfigurationCommand } from './set-configuration.command';
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens';
import { ConfigurationRepositoryPort } from '../../ports/configuration.repository.port';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
@CommandHandler(SetConfigurationCommand)
export class SetConfigurationService implements ICommandHandler {
constructor(
@Inject(CONFIGURATION_REPOSITORY)
private readonly repository: ConfigurationRepositoryPort,
) {}
async execute(command: SetConfigurationCommand): Promise<AggregateID> {
try {
const existingConfiguration: ConfigurationEntity =
await this.repository.findOne({
domain: command.domain,
key: command.key,
});
existingConfiguration.update(command);
await this.repository.update(
existingConfiguration.id,
existingConfiguration,
);
return existingConfiguration.id;
} catch (error: any) {
if (error instanceof NotFoundException) {
try {
const newConfiguration = ConfigurationEntity.create(command);
await this.repository.insert(newConfiguration);
return newConfiguration.id;
} catch (error: any) {
throw error;
}
}
throw error;
}
}
}

View File

@ -0,0 +1,33 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { CONFIGURATION_MESSAGE_PUBLISHER } from '@modules/configuration/configuration.di-tokens';
import { ConfigurationDeletedDomainEvent } from '../../domain/events/configuration-deleted.domain-event';
import { ConfigurationDeletedIntegrationEvent } from '../events/configuration-deleted.integration-event';
import { CONFIGURATION_DELETED_ROUTING_KEY } from '@src/app.constants';
@Injectable()
export class PublishMessageWhenConfigurationIsDeletedDomainEventHandler {
constructor(
@Inject(CONFIGURATION_MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
) {}
@OnEvent(ConfigurationDeletedDomainEvent.name, {
async: true,
promisify: true,
})
async handle(event: ConfigurationDeletedDomainEvent): Promise<any> {
const configurationDeletedIntegrationEvent =
new ConfigurationDeletedIntegrationEvent({
id: event.aggregateId,
domain: event.domain,
key: event.key,
metadata: event.metadata,
});
this.messagePublisher.publish(
CONFIGURATION_DELETED_ROUTING_KEY,
JSON.stringify(configurationDeletedIntegrationEvent),
);
}
}

View File

@ -0,0 +1,31 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { ConfigurationSetIntegrationEvent } from '../events/configuration-set.integration-event';
import { CONFIGURATION_MESSAGE_PUBLISHER } from '@modules/configuration/configuration.di-tokens';
import { ConfigurationSetDomainEvent } from '../../domain/events/configuration-set.domain-event';
import { CONFIGURATION_SET_ROUTING_KEY } from '@src/app.constants';
@Injectable()
export class PublishMessageWhenConfigurationIsSetDomainEventHandler {
constructor(
@Inject(CONFIGURATION_MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
) {}
@OnEvent(ConfigurationSetDomainEvent.name, { async: true, promisify: true })
async handle(event: ConfigurationSetDomainEvent): Promise<any> {
const configurationSetIntegrationEvent =
new ConfigurationSetIntegrationEvent({
id: event.aggregateId,
domain: event.domain,
key: event.key,
value: event.value,
metadata: event.metadata,
});
this.messagePublisher.publish(
CONFIGURATION_SET_ROUTING_KEY,
JSON.stringify(configurationSetIntegrationEvent),
);
}
}

View File

@ -0,0 +1,14 @@
import { IntegrationEvent, IntegrationEventProps } from '@mobicoop/ddd-library';
export class ConfigurationDeletedIntegrationEvent extends IntegrationEvent {
readonly domain: string;
readonly key: string;
constructor(
props: IntegrationEventProps<ConfigurationDeletedIntegrationEvent>,
) {
super(props);
this.domain = props.domain;
this.key = props.key;
}
}

View File

@ -0,0 +1,14 @@
import { IntegrationEvent, IntegrationEventProps } from '@mobicoop/ddd-library';
export class ConfigurationSetIntegrationEvent extends IntegrationEvent {
readonly domain: string;
readonly key: string;
readonly value: string;
constructor(props: IntegrationEventProps<ConfigurationSetIntegrationEvent>) {
super(props);
this.domain = props.domain;
this.key = props.key;
this.value = props.value;
}
}

View File

@ -0,0 +1,4 @@
import { RepositoryPort } from '@mobicoop/ddd-library';
import { ConfigurationEntity } from '../../domain/configuration.entity';
export type ConfigurationRepositoryPort = RepositoryPort<ConfigurationEntity>;

View File

@ -0,0 +1,17 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { GetConfigurationQuery } from './get-configuration.query';
import { Inject } from '@nestjs/common';
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens';
import { ConfigurationRepositoryPort } from '../../ports/configuration.repository.port';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
@QueryHandler(GetConfigurationQuery)
export class GetConfigurationQueryHandler implements IQueryHandler {
constructor(
@Inject(CONFIGURATION_REPOSITORY)
private readonly configurationRepository: ConfigurationRepositoryPort,
) {}
async execute(query: GetConfigurationQuery): Promise<ConfigurationEntity> {
return await this.configurationRepository.findOne(query);
}
}

View File

@ -0,0 +1,12 @@
import { QueryBase } from '@mobicoop/ddd-library';
export class GetConfigurationQuery extends QueryBase {
readonly domain: string;
readonly key: string;
constructor(domain: string, key: string) {
super();
this.domain = domain;
this.key = key;
}
}

View File

@ -0,0 +1,54 @@
import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library';
import { v4 } from 'uuid';
import {
ConfigurationProps,
CreateConfigurationProps,
UpdateConfigurationProps,
} from './configuration.types';
import { ConfigurationSetDomainEvent } from './events/configuration-set.domain-event';
import { ConfigurationDeletedDomainEvent } from './events/configuration-deleted.domain-event';
export class ConfigurationEntity extends AggregateRoot<ConfigurationProps> {
protected readonly _id: AggregateID;
static create = (create: CreateConfigurationProps): ConfigurationEntity => {
const id = v4();
const props: ConfigurationProps = { ...create };
const configuration = new ConfigurationEntity({ id, props });
configuration.addEvent(
new ConfigurationSetDomainEvent({
aggregateId: id,
domain: props.domain,
key: props.key,
value: props.value,
}),
);
return configuration;
};
update(props: UpdateConfigurationProps): void {
this.props.value = props.value;
this.addEvent(
new ConfigurationSetDomainEvent({
aggregateId: this._id,
domain: this.props.domain,
key: this.props.key,
value: props.value,
}),
);
}
delete(): void {
this.addEvent(
new ConfigurationDeletedDomainEvent({
aggregateId: this.id,
domain: this.props.domain,
key: this.props.key,
}),
);
}
validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database
}
}

View File

@ -0,0 +1,23 @@
// All properties that a Configuration has
export interface ConfigurationProps {
domain: string;
key: string;
value: string;
}
// Properties that are needed for a Configuration creation
export interface CreateConfigurationProps {
domain: string;
key: string;
value: string;
}
export interface UpdateConfigurationProps {
value: string;
}
export enum Domain {
AD = 'AD',
MATCHER = 'MATCHER',
USER = 'USER',
}

View File

@ -0,0 +1,12 @@
import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library';
export class ConfigurationDeletedDomainEvent extends DomainEvent {
readonly domain: string;
readonly key: string;
constructor(props: DomainEventProps<ConfigurationDeletedDomainEvent>) {
super(props);
this.domain = props.domain;
this.key = props.key;
}
}

View File

@ -0,0 +1,14 @@
import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library';
export class ConfigurationSetDomainEvent extends DomainEvent {
readonly domain: string;
readonly key: string;
readonly value: string;
constructor(props: DomainEventProps<ConfigurationSetDomainEvent>) {
super(props);
this.domain = props.domain;
this.key = props.key;
this.value = props.value;
}
}

View File

@ -1,25 +0,0 @@
import { AutoMap } from '@automapper/classes';
import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { Domain } from './domain.enum';
export class CreateConfigurationRequest {
@IsString()
@IsOptional()
@AutoMap()
uuid?: string;
@IsEnum(Domain)
@IsNotEmpty()
@AutoMap()
domain: Domain;
@IsString()
@IsNotEmpty()
@AutoMap()
key: string;
@IsString()
@IsNotEmpty()
@AutoMap()
value: string;
}

View File

@ -1,4 +0,0 @@
export enum Domain {
USER = 'USER',
MATCHER = 'MATCHER',
}

View File

@ -1,11 +0,0 @@
import { IsInt, IsOptional } from 'class-validator';
export class FindAllConfigurationsRequest {
@IsInt()
@IsOptional()
page?: number;
@IsInt()
@IsOptional()
perPage?: number;
}

View File

@ -1,7 +0,0 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class FindConfigurationByUuidRequest {
@IsString()
@IsNotEmpty()
uuid: string;
}

View File

@ -1,14 +0,0 @@
import { AutoMap } from '@automapper/classes';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class UpdateConfigurationRequest {
@IsString()
@IsNotEmpty()
@AutoMap()
uuid: string;
@IsString()
@IsOptional()
@AutoMap()
value: string;
}

View File

@ -1,16 +0,0 @@
import { AutoMap } from '@automapper/classes';
import { Domain } from '../dtos/domain.enum';
export class Configuration {
@AutoMap()
uuid: string;
@AutoMap()
domain: Domain;
@AutoMap()
key: string;
@AutoMap()
value: string;
}

View File

@ -1,12 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export abstract class IMessageBroker {
exchange: string;
constructor(exchange: string) {
this.exchange = exchange;
}
abstract publish(routingKey: string, message: string): void;
}

View File

@ -1,60 +0,0 @@
import { Mapper } from '@automapper/core';
import { InjectMapper } from '@automapper/nestjs';
import { CommandHandler } from '@nestjs/cqrs';
import { ConfigurationMessagerPresenter } from '../../adapters/secondaries/configuration-messager.presenter';
import { Messager } from '../../adapters/secondaries/messager';
import { ConfigurationRepository } from '../../adapters/secondaries/configuration.repository';
import { CreateConfigurationCommand } from '../../commands/create-configuration.command';
import { CreateConfigurationRequest } from '../dtos/create-configuration.request';
import { Configuration } from '../entities/configuration';
@CommandHandler(CreateConfigurationCommand)
export class CreateConfigurationUseCase {
constructor(
private readonly repository: ConfigurationRepository,
private readonly messager: Messager,
@InjectMapper() private readonly mapper: Mapper,
) {}
execute = async (
command: CreateConfigurationCommand,
): Promise<Configuration> => {
const entity = this.mapper.map(
command.createConfigurationRequest,
CreateConfigurationRequest,
Configuration,
);
try {
const configuration = await this.repository.create(entity);
this.messager.publish(
'configuration.create',
JSON.stringify(
this.mapper.map(
configuration,
Configuration,
ConfigurationMessagerPresenter,
),
),
);
this.messager.publish(
'logging.configuration.create.info',
JSON.stringify(configuration),
);
return configuration;
} catch (error) {
let key = 'logging.configuration.create.crit';
if (error.message.includes('Already exists')) {
key = 'logging.configuration.create.warning';
}
this.messager.publish(
key,
JSON.stringify({
command,
error,
}),
);
throw error;
}
};
}

View File

@ -1,49 +0,0 @@
import { Mapper } from '@automapper/core';
import { InjectMapper } from '@automapper/nestjs';
import { CommandHandler } from '@nestjs/cqrs';
import { ConfigurationMessagerPresenter } from '../../adapters/secondaries/configuration-messager.presenter';
import { Messager } from '../../adapters/secondaries/messager';
import { ConfigurationRepository } from '../../adapters/secondaries/configuration.repository';
import { DeleteConfigurationCommand } from '../../commands/delete-configuration.command';
import { Configuration } from '../entities/configuration';
@CommandHandler(DeleteConfigurationCommand)
export class DeleteConfigurationUseCase {
constructor(
private readonly repository: ConfigurationRepository,
private readonly messager: Messager,
@InjectMapper() private readonly mapper: Mapper,
) {}
execute = async (
command: DeleteConfigurationCommand,
): Promise<Configuration> => {
try {
const configuration = await this.repository.delete(command.uuid);
this.messager.publish(
'configuration.delete',
JSON.stringify(
this.mapper.map(
configuration,
Configuration,
ConfigurationMessagerPresenter,
),
),
);
this.messager.publish(
'logging.configuration.delete.info',
JSON.stringify({ uuid: configuration.uuid }),
);
return configuration;
} catch (error) {
this.messager.publish(
'logging.configuration.delete.crit',
JSON.stringify({
command,
error,
}),
);
throw error;
}
};
}

View File

@ -1,18 +0,0 @@
import { QueryHandler } from '@nestjs/cqrs';
import { ICollection } from 'src/modules/database/interfaces/collection.interface';
import { ConfigurationRepository } from '../../adapters/secondaries/configuration.repository';
import { FindAllConfigurationsQuery } from '../../queries/find-all-configurations.query';
import { Configuration } from '../entities/configuration';
@QueryHandler(FindAllConfigurationsQuery)
export class FindAllConfigurationsUseCase {
constructor(private readonly repository: ConfigurationRepository) {}
execute = async (
findAllConfigurationsQuery: FindAllConfigurationsQuery,
): Promise<ICollection<Configuration>> =>
this.repository.findAll(
findAllConfigurationsQuery.page,
findAllConfigurationsQuery.perPage,
);
}

View File

@ -1,35 +0,0 @@
import { NotFoundException } from '@nestjs/common';
import { QueryHandler } from '@nestjs/cqrs';
import { ConfigurationRepository } from '../../adapters/secondaries/configuration.repository';
import { Messager } from '../../adapters/secondaries/messager';
import { FindConfigurationByUuidQuery } from '../../queries/find-configuration-by-uuid.query';
import { Configuration } from '../entities/configuration';
@QueryHandler(FindConfigurationByUuidQuery)
export class FindConfigurationByUuidUseCase {
constructor(
private readonly repository: ConfigurationRepository,
private readonly messager: Messager,
) {}
execute = async (
findConfigurationByUuid: FindConfigurationByUuidQuery,
): Promise<Configuration> => {
try {
const configuration = await this.repository.findOneByUuid(
findConfigurationByUuid.uuid,
);
if (!configuration) throw new NotFoundException();
return configuration;
} catch (error) {
this.messager.publish(
'logging.configuration.read.warning',
JSON.stringify({
query: findConfigurationByUuid,
error,
}),
);
throw error;
}
};
}

View File

@ -1,42 +0,0 @@
import { Mapper } from '@automapper/core';
import { InjectMapper } from '@automapper/nestjs';
import { QueryHandler } from '@nestjs/cqrs';
import { ConfigurationMessagerPresenter } from '../../adapters/secondaries/configuration-messager.presenter';
import { Messager } from '../../adapters/secondaries/messager';
import { ConfigurationRepository } from '../../adapters/secondaries/configuration.repository';
import { PropagateConfigurationsQuery } from '../../queries/propagate-configurations.query';
import { Configuration } from '../entities/configuration';
@QueryHandler(PropagateConfigurationsQuery)
export class PropagateConfigurationsUseCase {
constructor(
private readonly repository: ConfigurationRepository,
private readonly messager: Messager,
@InjectMapper() private readonly mapper: Mapper,
) {}
execute = async (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
propagateConfigurationsQuery: PropagateConfigurationsQuery,
) => {
try {
const configurations = await this.repository.findAll(1, 999999);
this.messager.publish(
'configuration.propagate',
JSON.stringify(
configurations.data.map((configuration) =>
this.mapper.map(
configuration,
Configuration,
ConfigurationMessagerPresenter,
),
),
),
);
this.messager.publish('logging.configuration.update.info', 'propagation');
} catch (error) {
this.messager.publish('logging.configuration.update.crit', 'propagation');
throw error;
}
};
}

View File

@ -1,59 +0,0 @@
import { Mapper } from '@automapper/core';
import { InjectMapper } from '@automapper/nestjs';
import { CommandHandler } from '@nestjs/cqrs';
import { ConfigurationMessagerPresenter } from '../../adapters/secondaries/configuration-messager.presenter';
import { Messager } from '../../adapters/secondaries/messager';
import { ConfigurationRepository } from '../../adapters/secondaries/configuration.repository';
import { UpdateConfigurationCommand } from '../../commands/update-configuration.command';
import { UpdateConfigurationRequest } from '../dtos/update-configuration.request';
import { Configuration } from '../entities/configuration';
@CommandHandler(UpdateConfigurationCommand)
export class UpdateConfigurationUseCase {
constructor(
private readonly repository: ConfigurationRepository,
private readonly messager: Messager,
@InjectMapper() private readonly mapper: Mapper,
) {}
execute = async (
command: UpdateConfigurationCommand,
): Promise<Configuration> => {
const entity = this.mapper.map(
command.updateConfigurationRequest,
UpdateConfigurationRequest,
Configuration,
);
try {
const configuration = await this.repository.update(
command.updateConfigurationRequest.uuid,
entity,
);
this.messager.publish(
'configuration.update',
JSON.stringify(
this.mapper.map(
configuration,
Configuration,
ConfigurationMessagerPresenter,
),
),
);
this.messager.publish(
'logging.configuration.update.info',
JSON.stringify(command.updateConfigurationRequest),
);
return configuration;
} catch (error) {
this.messager.publish(
'logging.configuration.update.crit',
JSON.stringify({
command,
error,
}),
);
throw error;
}
};
}

View File

@ -0,0 +1,60 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
LoggerBase,
MessagePublisherPort,
PrismaRepositoryBase,
} from '@mobicoop/ddd-library';
import { SERVICE_NAME } from '@src/app.constants';
import { ConfigurationEntity } from '../core/domain/configuration.entity';
import { ConfigurationRepositoryPort } from '../core/application/ports/configuration.repository.port';
import { PrismaService } from './prisma.service';
import { CONFIGURATION_MESSAGE_PUBLISHER } from '../configuration.di-tokens';
import { ConfigurationMapper } from '../configuration.mapper';
export type ConfigurationBaseModel = {
uuid: string;
domain: string;
key: string;
value: string;
};
export type ConfigurationReadModel = ConfigurationBaseModel & {
createdAt: Date;
updatedAt: Date;
};
export type ConfigurationWriteModel = ConfigurationBaseModel;
/**
* Repository is used for retrieving/saving domain entities
* */
@Injectable()
export class ConfigurationRepository
extends PrismaRepositoryBase<
ConfigurationEntity,
ConfigurationReadModel,
ConfigurationWriteModel
>
implements ConfigurationRepositoryPort
{
constructor(
prisma: PrismaService,
mapper: ConfigurationMapper,
eventEmitter: EventEmitter2,
@Inject(CONFIGURATION_MESSAGE_PUBLISHER)
protected readonly messagePublisher: MessagePublisherPort,
) {
super(
prisma.configuration,
prisma,
mapper,
eventEmitter,
new LoggerBase({
logger: new Logger(ConfigurationRepository.name),
domain: SERVICE_NAME,
messagePublisher,
}),
);
}
}

View File

@ -0,0 +1,9 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}

View File

@ -0,0 +1,7 @@
import { ResponseBase } from '@mobicoop/ddd-library';
export class ConfigurationResponseDto extends ResponseBase {
domain: string;
key: string;
value: string;
}

View File

@ -0,0 +1,26 @@
syntax = "proto3";
package configuration;
service ConfigurationService {
rpc Get(ConfigurationByDomainKey) returns (Configuration);
rpc Set(Configuration) returns (ConfigurationId);
rpc Delete(ConfigurationByDomainKey) returns (Empty);
rpc Propagate(Empty) returns (Empty);
}
message ConfigurationId {
string id = 1;
}
message ConfigurationByDomainKey {
string domain = 1;
string key = 2;
}
message Configuration {
string id = 1;
string domain = 2;
string key = 3;
string value = 4;
}
message Empty {}

View File

@ -0,0 +1,45 @@
import {
DatabaseErrorException,
NotFoundException,
RpcExceptionCode,
RpcValidationPipe,
} from '@mobicoop/ddd-library';
import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { DeleteConfigurationRequestDto } from './dtos/delete-configuration.request.dto';
import { GRPC_SERVICE_NAME } from '@src/app.constants';
import { DeleteConfigurationCommand } from '@modules/configuration/core/application/commands/delete-configuration/delete-configuration.command';
@UsePipes(
new RpcValidationPipe({
whitelist: true,
forbidUnknownValues: false,
}),
)
@Controller()
export class DeleteConfigurationGrpcController {
constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod(GRPC_SERVICE_NAME, 'Delete')
async delete(data: DeleteConfigurationRequestDto): Promise<void> {
try {
await this.commandBus.execute(new DeleteConfigurationCommand(data));
} catch (error: any) {
if (error instanceof NotFoundException)
throw new RpcException({
code: RpcExceptionCode.NOT_FOUND,
message: error.message,
});
if (error instanceof DatabaseErrorException)
throw new RpcException({
code: RpcExceptionCode.INTERNAL,
message: error.message,
});
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: error.message,
});
}
}
}

View File

@ -0,0 +1,12 @@
import { Domain } from '@modules/configuration/core/domain/configuration.types';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
export class DeleteConfigurationRequestDto {
@IsEnum(Domain)
@IsNotEmpty()
domain: Domain;
@IsString()
@IsNotEmpty()
key: string;
}

View File

@ -0,0 +1,12 @@
import { Domain } from '@modules/configuration/core/domain/configuration.types';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
export class GetConfigurationRequestDto {
@IsEnum(Domain)
@IsNotEmpty()
domain: Domain;
@IsString()
@IsNotEmpty()
key: string;
}

View File

@ -0,0 +1,16 @@
import { Domain } from '@modules/configuration/core/domain/configuration.types';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
export class SetConfigurationRequestDto {
@IsEnum(Domain)
@IsNotEmpty()
domain: Domain;
@IsString()
@IsNotEmpty()
key: string;
@IsString()
@IsNotEmpty()
value: string;
}

View File

@ -0,0 +1,49 @@
import { Controller, UsePipes } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { NotFoundException } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { RpcValidationPipe } from '@mobicoop/ddd-library';
import { GRPC_SERVICE_NAME } from '@src/app.constants';
import { ConfigurationMapper } from '@modules/configuration/configuration.mapper';
import { GetConfigurationRequestDto } from './dtos/get-configuration.request.dto';
import { ConfigurationResponseDto } from '../dtos/configuration.response.dto';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import { GetConfigurationQuery } from '@modules/configuration/core/application/queries/get-configuration/get-configuration.query';
@UsePipes(
new RpcValidationPipe({
whitelist: false,
forbidUnknownValues: false,
}),
)
@Controller()
export class GetConfigurationGrpcController {
constructor(
protected readonly mapper: ConfigurationMapper,
private readonly queryBus: QueryBus,
) {}
@GrpcMethod(GRPC_SERVICE_NAME, 'Get')
async get(
data: GetConfigurationRequestDto,
): Promise<ConfigurationResponseDto> {
try {
const configuration: ConfigurationEntity = await this.queryBus.execute(
new GetConfigurationQuery(data.domain, data.key),
);
return this.mapper.toResponse(configuration);
} catch (e) {
if (e instanceof NotFoundException) {
throw new RpcException({
code: RpcExceptionCode.NOT_FOUND,
message: e.message,
});
}
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: e.message,
});
}
}
}

View File

@ -0,0 +1,33 @@
import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { RpcValidationPipe } from '@mobicoop/ddd-library';
import { GRPC_SERVICE_NAME } from '@src/app.constants';
import { PropagateConfigurationsCommand } from '@modules/configuration/core/application/commands/propagate-configurations/propagate-configurations.command';
import { v4 } from 'uuid';
@UsePipes(
new RpcValidationPipe({
whitelist: false,
forbidUnknownValues: false,
}),
)
@Controller()
export class PropagateConfigurationsGrpcController {
constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod(GRPC_SERVICE_NAME, 'Propagate')
async propagate(): Promise<void> {
try {
await this.commandBus.execute(
new PropagateConfigurationsCommand({ id: v4() }),
);
} catch (error: any) {
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: error.message,
});
}
}
}

View File

@ -0,0 +1,38 @@
import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { AggregateID } from '@mobicoop/ddd-library';
import { IdResponse } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { RpcValidationPipe } from '@mobicoop/ddd-library';
import { GRPC_SERVICE_NAME } from '@src/app.constants';
import { SetConfigurationRequestDto } from './dtos/set-configuration.request.dto';
import { SetConfigurationCommand } from '@modules/configuration/core/application/commands/set-configuration/set-configuration.command';
@UsePipes(
new RpcValidationPipe({
whitelist: false,
forbidUnknownValues: false,
}),
)
@Controller()
export class SetConfigurationGrpcController {
constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod(GRPC_SERVICE_NAME, 'Set')
async set(
setConfigurationRequestDto: SetConfigurationRequestDto,
): Promise<IdResponse> {
try {
const aggregateID: AggregateID = await this.commandBus.execute(
new SetConfigurationCommand(setConfigurationRequestDto),
);
return new IdResponse(aggregateID);
} catch (error: any) {
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: error.message,
});
}
}
}

View File

@ -1,32 +0,0 @@
import { createMap, forMember, ignore, Mapper } from '@automapper/core';
import { AutomapperProfile, InjectMapper } from '@automapper/nestjs';
import { Injectable } from '@nestjs/common';
import { ConfigurationPresenter } from '../adapters/primaries/configuration.presenter';
import { ConfigurationMessagerPresenter } from '../adapters/secondaries/configuration-messager.presenter';
import { CreateConfigurationRequest } from '../domain/dtos/create-configuration.request';
import { UpdateConfigurationRequest } from '../domain/dtos/update-configuration.request';
import { Configuration } from '../domain/entities/configuration';
@Injectable()
export class ConfigurationProfile extends AutomapperProfile {
constructor(@InjectMapper() mapper: Mapper) {
super(mapper);
}
override get profile() {
return (mapper: any) => {
createMap(mapper, Configuration, ConfigurationPresenter);
createMap(mapper, CreateConfigurationRequest, Configuration);
createMap(
mapper,
UpdateConfigurationRequest,
Configuration,
forMember((dest) => dest.uuid, ignore()),
);
createMap(mapper, Configuration, ConfigurationMessagerPresenter);
};
}
}

View File

@ -1,11 +0,0 @@
import { FindAllConfigurationsRequest } from '../domain/dtos/find-all-configurations.request';
export class FindAllConfigurationsQuery {
page: number;
perPage: number;
constructor(findAllConfigurationsRequest?: FindAllConfigurationsRequest) {
this.page = findAllConfigurationsRequest?.page ?? 1;
this.perPage = findAllConfigurationsRequest?.perPage ?? 10;
}
}

View File

@ -1,9 +0,0 @@
import { FindConfigurationByUuidRequest } from '../domain/dtos/find-configuration-by-uuid.request';
export class FindConfigurationByUuidQuery {
readonly uuid: string;
constructor(findConfigurationByUuidRequest: FindConfigurationByUuidRequest) {
this.uuid = findConfigurationByUuidRequest.uuid;
}
}

View File

@ -1 +0,0 @@
export class PropagateConfigurationsQuery {}

View File

@ -1,36 +1,88 @@
import { TestingModule, Test } from '@nestjs/testing';
import { DatabaseModule } from '../../../database/database.module';
import { PrismaService } from '../../../database/adapters/secondaries/prisma-service';
import { DatabaseException } from '../../../database/exceptions/database.exception';
import { ConfigurationRepository } from '../../adapters/secondaries/configuration.repository';
import { Domain } from '../../domain/dtos/domain.enum';
import { Configuration } from '../../domain/entities/configuration';
import {
CONFIGURATION_MESSAGE_PUBLISHER,
CONFIGURATION_REPOSITORY,
} from '@modules/configuration/configuration.di-tokens';
import { ConfigurationMapper } from '@modules/configuration/configuration.mapper';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import {
CreateConfigurationProps,
Domain,
} from '@modules/configuration/core/domain/configuration.types';
import { ConfigurationRepository } from '@modules/configuration/infrastructure/configuration.repository';
import { PrismaService } from '@modules/configuration/infrastructure/prisma.service';
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { Test } from '@nestjs/testing';
describe('ConfigurationRepository', () => {
describe('Configuration Repository', () => {
let prismaService: PrismaService;
let configurationRepository: ConfigurationRepository;
const executeInsertCommand = async (table: string, object: any) => {
const command = `INSERT INTO "${table}" ("${Object.keys(object).join(
'","',
)}") VALUES ('${Object.values(object).join("','")}')`;
await prismaService.$executeRawUnsafe(command);
};
const getSeed = (index: number, uuid: string): string => {
return `${uuid.slice(0, -2)}${index.toString(16).padStart(2, '0')}`;
};
const baseUuid = {
uuid: 'be459a29-7a41-4c0b-b371-abe90bfb6f00',
};
const createConfigurations = async (nbToCreate = 10) => {
for (let i = 0; i < nbToCreate; i++) {
await prismaService.configuration.create({
data: {
domain: Domain.USER,
key: `key-${i}`,
value: `key-${i}`,
},
});
const configurationToCreate = {
uuid: getSeed(i, baseUuid.uuid),
domain: Domain.AD,
key: `key${i}`,
value: `value${i}`,
createdAt: '2023-07-24 13:07:05.000',
updatedAt: '2023-07-24 13:07:05.000',
};
configurationToCreate.uuid = getSeed(i, baseUuid.uuid);
await executeInsertCommand('configuration', configurationToCreate);
}
};
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
const mockLogger = {
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [DatabaseModule],
providers: [ConfigurationRepository, PrismaService],
}).compile();
const module = await Test.createTestingModule({
imports: [
EventEmitterModule.forRoot(),
ConfigModule.forRoot({ isGlobal: true }),
],
providers: [
PrismaService,
ConfigurationMapper,
{
provide: CONFIGURATION_REPOSITORY,
useClass: ConfigurationRepository,
},
{
provide: CONFIGURATION_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
],
})
// disable logging
.setLogger(mockLogger)
.compile();
prismaService = module.get<PrismaService>(PrismaService);
configurationRepository = module.get<ConfigurationRepository>(
ConfigurationRepository,
CONFIGURATION_REPOSITORY,
);
});
@ -42,78 +94,23 @@ describe('ConfigurationRepository', () => {
await prismaService.configuration.deleteMany();
});
describe('findAll', () => {
it('should return an empty data array', async () => {
const res = await configurationRepository.findAll();
expect(res).toEqual({
data: [],
total: 0,
});
});
it('should return a data array with 8 configurations', async () => {
await createConfigurations(8);
const configurations = await configurationRepository.findAll();
expect(configurations.data.length).toBe(8);
expect(configurations.total).toBe(8);
});
it('should return a data array limited to 10 configurations', async () => {
await createConfigurations(20);
const configurations = await configurationRepository.findAll();
expect(configurations.data.length).toBe(10);
expect(configurations.total).toBe(20);
});
});
describe('findOneByUuid', () => {
it('should return a configuration', async () => {
const configurationToFind = await prismaService.configuration.create({
data: {
domain: Domain.USER,
key: 'key1',
value: 'value1',
},
});
const configuration = await configurationRepository.findOneByUuid(
configurationToFind.uuid,
);
expect(configuration.uuid).toBe(configurationToFind.uuid);
});
it('should return null', async () => {
const configuration = await configurationRepository.findOneByUuid(
'544572be-11fb-4244-8235-587221fc9104',
);
expect(configuration).toBeNull();
});
});
describe('findOne', () => {
it('should return a configuration according to its domain and key', async () => {
const configurationToFind = await prismaService.configuration.create({
data: {
domain: Domain.USER,
key: 'key1',
value: 'value1',
},
it('should return a configuration', async () => {
await createConfigurations(1);
const result = await configurationRepository.findOne({
domain: Domain.AD,
key: 'key0',
});
expect(result.getProps().value).toBe('value0');
});
});
const configuration = await configurationRepository.findOne({
domain: Domain.USER,
key: 'key1',
});
expect(configuration.uuid).toBe(configurationToFind.uuid);
});
it('should return null with unknown domain and key', async () => {
const configuration = await configurationRepository.findOne({
domain: Domain.USER,
key: 'key1',
});
expect(configuration).toBeNull();
describe('findAll', () => {
it('should return all configurations', async () => {
await createConfigurations(10);
const configurations: ConfigurationEntity[] =
await configurationRepository.findAll({});
expect(configurations).toHaveLength(10);
});
});
@ -121,75 +118,57 @@ describe('ConfigurationRepository', () => {
it('should create a configuration', async () => {
const beforeCount = await prismaService.configuration.count();
const configurationToCreate: Configuration = new Configuration();
configurationToCreate.domain = Domain.USER;
configurationToCreate.key = 'key1';
configurationToCreate.value = 'value1';
const configuration = await configurationRepository.create(
configurationToCreate,
);
const createConfigurationProps: CreateConfigurationProps = {
domain: Domain.AD,
key: 'seatsProposed',
value: '3',
};
const configurationToCreate: ConfigurationEntity =
ConfigurationEntity.create(createConfigurationProps);
await configurationRepository.insert(configurationToCreate);
const afterCount = await prismaService.configuration.count();
expect(afterCount - beforeCount).toBe(1);
expect(configuration.uuid).toBeDefined();
});
});
describe('update', () => {
it('should update configuration value', async () => {
const configurationToUpdate = await prismaService.configuration.create({
data: {
domain: Domain.USER,
key: 'key1',
value: 'value1',
},
it('should update a configuration', async () => {
await createConfigurations(1);
const configurationToUpdate: ConfigurationEntity =
await configurationRepository.findOne({
domain: Domain.AD,
key: 'key0',
});
const toUpdate: Configuration = new Configuration();
toUpdate.value = 'value2';
const updatedConfiguration = await configurationRepository.update(
configurationToUpdate.uuid,
toUpdate,
configurationToUpdate.update({ value: 'newValue' });
await configurationRepository.update(
configurationToUpdate.id,
configurationToUpdate,
);
expect(updatedConfiguration.uuid).toBe(configurationToUpdate.uuid);
expect(updatedConfiguration.value).toBe('value2');
});
it('should throw DatabaseException', async () => {
const toUpdate: Configuration = new Configuration();
toUpdate.key = 'updated';
await expect(
configurationRepository.update(
'544572be-11fb-4244-8235-587221fc9104',
toUpdate,
),
).rejects.toBeInstanceOf(DatabaseException);
const result: ConfigurationEntity = await configurationRepository.findOne(
{
domain: Domain.AD,
key: 'key0',
},
);
expect(result.getProps().value).toBe('newValue');
});
});
describe('delete', () => {
it('should delete a configuration', async () => {
const configurationToRemove = await prismaService.configuration.create({
data: {
domain: Domain.USER,
key: 'key1',
value: 'value1',
},
await createConfigurations(10);
const beforeCount = await prismaService.configuration.count();
const configurationToDelete: ConfigurationEntity =
await configurationRepository.findOne({
domain: Domain.AD,
key: 'key4',
});
await configurationRepository.delete(configurationToRemove.uuid);
const count = await prismaService.configuration.count();
expect(count).toBe(0);
});
it('should throw DatabaseException', async () => {
await expect(
configurationRepository.delete('544572be-11fb-4244-8235-587221fc9104'),
).rejects.toBeInstanceOf(DatabaseException);
await configurationRepository.delete(configurationToDelete);
const afterCount = await prismaService.configuration.count();
expect(afterCount - beforeCount).toBe(-1);
});
});
});

View File

@ -0,0 +1,63 @@
import { ConfigurationMapper } from '@modules/configuration/configuration.mapper';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import { Domain } from '@modules/configuration/core/domain/configuration.types';
import {
ConfigurationReadModel,
ConfigurationWriteModel,
} from '@modules/configuration/infrastructure/configuration.repository';
import { ConfigurationResponseDto } from '@modules/configuration/interface/dtos/configuration.response.dto';
import { Test } from '@nestjs/testing';
const now = new Date('2023-06-21 06:00:00');
const configurationEntity: ConfigurationEntity = new ConfigurationEntity({
id: 'c160cf8c-f057-4962-841f-3ad68346df44',
props: {
domain: Domain.AD,
key: 'seatsProposed',
value: '3',
},
createdAt: now,
updatedAt: now,
});
const configurationReadModel: ConfigurationReadModel = {
uuid: 'c160cf8c-f057-4962-841f-3ad68346df44',
domain: 'AD',
key: 'seatsProposed',
value: '4',
createdAt: now,
updatedAt: now,
};
describe('Configuration Mapper', () => {
let configurationMapper: ConfigurationMapper;
beforeAll(async () => {
const module = await Test.createTestingModule({
providers: [ConfigurationMapper],
}).compile();
configurationMapper = module.get<ConfigurationMapper>(ConfigurationMapper);
});
it('should be defined', () => {
expect(configurationMapper).toBeDefined();
});
it('should map domain entity to persistence data', async () => {
const mapped: ConfigurationWriteModel =
configurationMapper.toPersistence(configurationEntity);
expect(mapped.value).toBe('3');
});
it('should map persisted data to domain entity', async () => {
const mapped: ConfigurationEntity = configurationMapper.toDomain(
configurationReadModel,
);
expect(mapped.getProps().value).toBe('4');
});
it('should map domain entity to response', async () => {
const mapped: ConfigurationResponseDto =
configurationMapper.toResponse(configurationEntity);
expect(mapped.id).toBe('c160cf8c-f057-4962-841f-3ad68346df44');
});
});

View File

@ -0,0 +1,61 @@
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import {
CreateConfigurationProps,
Domain,
UpdateConfigurationProps,
} from '@modules/configuration/core/domain/configuration.types';
import { ConfigurationDeletedDomainEvent } from '@modules/configuration/core/domain/events/configuration-deleted.domain-event';
import { ConfigurationSetDomainEvent } from '@modules/configuration/core/domain/events/configuration-set.domain-event';
const createConfigurationProps: CreateConfigurationProps = {
domain: Domain.AD,
key: 'seatsProposed',
value: '3',
};
const updateConfigurationProps: UpdateConfigurationProps = {
value: '2',
};
describe('Configuration entity create', () => {
it('should create a new configuration entity', async () => {
const configurationEntity: ConfigurationEntity = ConfigurationEntity.create(
createConfigurationProps,
);
expect(configurationEntity.id.length).toBe(36);
expect(configurationEntity.getProps().value).toBe('3');
expect(configurationEntity.domainEvents.length).toBe(1);
expect(configurationEntity.domainEvents[0]).toBeInstanceOf(
ConfigurationSetDomainEvent,
);
});
});
describe('Configuration entity update', () => {
it('should update a configuration entity', async () => {
const configurationEntity: ConfigurationEntity = ConfigurationEntity.create(
createConfigurationProps,
);
configurationEntity.update(updateConfigurationProps);
expect(configurationEntity.getProps().value).toBe('2');
// 2 events because ConfigurationEntity.create sends a ConfigurationSetDomainEvent
expect(configurationEntity.domainEvents.length).toBe(2);
expect(configurationEntity.domainEvents[1]).toBeInstanceOf(
ConfigurationSetDomainEvent,
);
});
});
describe('Configuration entity delete', () => {
it('should delete a configuration entity', async () => {
const configurationEntity: ConfigurationEntity = ConfigurationEntity.create(
createConfigurationProps,
);
configurationEntity.delete();
// 2 events because ConfigurationEntity.create sends a ConfigurationSetDomainEvent
expect(configurationEntity.domainEvents.length).toBe(2);
expect(configurationEntity.domainEvents[1]).toBeInstanceOf(
ConfigurationDeletedDomainEvent,
);
});
});

View File

@ -0,0 +1,56 @@
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens';
import { DeleteConfigurationCommand } from '@modules/configuration/core/application/commands/delete-configuration/delete-configuration.command';
import { DeleteConfigurationService } from '@modules/configuration/core/application/commands/delete-configuration/delete-configuration.service';
import { Domain } from '@modules/configuration/core/domain/configuration.types';
import { DeleteConfigurationRequestDto } from '@modules/configuration/interface/grpc-controllers/dtos/delete-configuration.request.dto';
import { Test, TestingModule } from '@nestjs/testing';
const deleteConfigurationRequest: DeleteConfigurationRequestDto = {
domain: Domain.AD,
key: 'seatsProposed',
};
const mockConfigurationEntity = {
delete: jest.fn(),
};
const mockConfigurationRepository = {
findOne: jest.fn().mockImplementation(() => mockConfigurationEntity),
delete: jest.fn().mockImplementationOnce(() => true),
};
describe('Delete Configuration Service', () => {
let deleteConfigurationService: DeleteConfigurationService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CONFIGURATION_REPOSITORY,
useValue: mockConfigurationRepository,
},
DeleteConfigurationService,
],
}).compile();
deleteConfigurationService = module.get<DeleteConfigurationService>(
DeleteConfigurationService,
);
});
it('should be defined', () => {
expect(deleteConfigurationService).toBeDefined();
});
describe('execution', () => {
const deleteConfigurationCommand = new DeleteConfigurationCommand(
deleteConfigurationRequest,
);
it('should delete a configuration item', async () => {
const result: boolean = await deleteConfigurationService.execute(
deleteConfigurationCommand,
);
expect(result).toBeTruthy();
});
});
});

View File

@ -0,0 +1,58 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import { Domain } from '@modules/configuration/core/domain/configuration.types';
import { GetConfigurationQueryHandler } from '@modules/configuration/core/application/queries/get-configuration/get-configuration.query-handler';
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens';
import { GetConfigurationQuery } from '@modules/configuration/core/application/queries/get-configuration/get-configuration.query';
const now = new Date('2023-06-21 06:00:00');
const configuration: ConfigurationEntity = new ConfigurationEntity({
id: 'c160cf8c-f057-4962-841f-3ad68346df44',
props: {
domain: Domain.AD,
key: 'seatsProposed',
value: '3',
},
createdAt: now,
updatedAt: now,
});
const mockConfigurationRepository = {
findOne: jest.fn().mockImplementation(() => configuration),
};
describe('Get Configuration Query Handler', () => {
let getConfigurationQueryHandler: GetConfigurationQueryHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CONFIGURATION_REPOSITORY,
useValue: mockConfigurationRepository,
},
GetConfigurationQueryHandler,
],
}).compile();
getConfigurationQueryHandler = module.get<GetConfigurationQueryHandler>(
GetConfigurationQueryHandler,
);
});
it('should be defined', () => {
expect(getConfigurationQueryHandler).toBeDefined();
});
describe('execution', () => {
it('should return a configuration item', async () => {
const getConfigurationQuery = new GetConfigurationQuery(
Domain.AD,
'seatsProposed',
);
const configuration: ConfigurationEntity =
await getConfigurationQueryHandler.execute(getConfigurationQuery);
expect(configuration.getProps().value).toBe('3');
});
});
});

View File

@ -0,0 +1,100 @@
import { Test, TestingModule } from '@nestjs/testing';
import { Domain } from '@modules/configuration/core/domain/configuration.types';
import {
CONFIGURATION_MESSAGE_PUBLISHER,
CONFIGURATION_REPOSITORY,
} from '@modules/configuration/configuration.di-tokens';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import { PropagateConfigurationsService } from '@modules/configuration/core/application/commands/propagate-configurations/propagate-configurations.service';
import { CONFIGURATION_PROPAGATED_ROUTING_KEY } from '@src/app.constants';
import { ConfigurationMapper } from '@modules/configuration/configuration.mapper';
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
const configurationEntities = [
new ConfigurationEntity({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
props: {
domain: Domain.AD,
key: 'seatsProposed',
value: '3',
},
createdAt: new Date('2023-10-23T07:00:00Z'),
updatedAt: new Date('2023-10-23T07:00:00Z'),
}),
new ConfigurationEntity({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8db',
props: {
domain: Domain.AD,
key: 'seatsRequested',
value: '1',
},
createdAt: new Date('2023-10-23T07:00:00Z'),
updatedAt: new Date('2023-10-23T07:00:00Z'),
}),
];
const mockConfigurationMapper = {
toResponse: jest
.fn()
.mockImplementationOnce(() => ({
domain: Domain.AD,
key: 'seatsProposed',
value: '3',
}))
.mockImplementationOnce(() => ({
domain: Domain.AD,
key: 'seatsRequested',
value: '1',
})),
};
const mockConfigurationRepository = {
findAll: jest.fn().mockImplementationOnce(() => configurationEntities),
};
describe('Propagate Configurations Service', () => {
let propagateConfigurationsService: PropagateConfigurationsService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CONFIGURATION_REPOSITORY,
useValue: mockConfigurationRepository,
},
{
provide: CONFIGURATION_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
{
provide: ConfigurationMapper,
useValue: mockConfigurationMapper,
},
PropagateConfigurationsService,
],
}).compile();
propagateConfigurationsService = module.get<PropagateConfigurationsService>(
PropagateConfigurationsService,
);
});
it('should be defined', () => {
expect(propagateConfigurationsService).toBeDefined();
});
describe('execution', () => {
it('should propagate configuration items', async () => {
jest.spyOn(mockMessagePublisher, 'publish');
await propagateConfigurationsService.execute();
expect(mockMessagePublisher.publish).toHaveBeenCalledTimes(1);
expect(mockMessagePublisher.publish).toHaveBeenCalledWith(
CONFIGURATION_PROPAGATED_ROUTING_KEY,
'[{"domain":"AD","key":"seatsProposed","value":"3"},{"domain":"AD","key":"seatsRequested","value":"1"}]',
);
});
});
});

View File

@ -0,0 +1,56 @@
import { CONFIGURATION_MESSAGE_PUBLISHER } from '@modules/configuration/configuration.di-tokens';
import { PublishMessageWhenConfigurationIsDeletedDomainEventHandler } from '@modules/configuration/core/application/event-handlers/publish-message-when-configuration-is-deleted.domain-event-handler';
import { Domain } from '@modules/configuration/core/domain/configuration.types';
import { ConfigurationDeletedDomainEvent } from '@modules/configuration/core/domain/events/configuration-deleted.domain-event';
import { Test, TestingModule } from '@nestjs/testing';
import { CONFIGURATION_DELETED_ROUTING_KEY } from '@src/app.constants';
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('Publish message when configuration is deleted domain event handler', () => {
let publishMessageWhenConfigurationIsDeletedDomainEventHandler: PublishMessageWhenConfigurationIsDeletedDomainEventHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CONFIGURATION_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
PublishMessageWhenConfigurationIsDeletedDomainEventHandler,
],
}).compile();
publishMessageWhenConfigurationIsDeletedDomainEventHandler =
module.get<PublishMessageWhenConfigurationIsDeletedDomainEventHandler>(
PublishMessageWhenConfigurationIsDeletedDomainEventHandler,
);
});
it('should publish a message', () => {
jest.spyOn(mockMessagePublisher, 'publish');
const configurationDeletedDomainEvent: ConfigurationDeletedDomainEvent = {
id: 'some-domain-event-id',
domain: Domain.AD,
key: 'seatsProposed',
aggregateId: 'some-aggregate-id',
metadata: {
timestamp: new Date('2023-06-28T05:00:00Z').getTime(),
correlationId: 'some-correlation-id',
},
};
publishMessageWhenConfigurationIsDeletedDomainEventHandler.handle(
configurationDeletedDomainEvent,
);
expect(
publishMessageWhenConfigurationIsDeletedDomainEventHandler,
).toBeDefined();
expect(mockMessagePublisher.publish).toHaveBeenCalledTimes(1);
expect(mockMessagePublisher.publish).toHaveBeenCalledWith(
CONFIGURATION_DELETED_ROUTING_KEY,
'{"id":"some-aggregate-id","metadata":{"correlationId":"some-correlation-id","timestamp":1687928400000},"domain":"AD","key":"seatsProposed"}',
);
});
});

View File

@ -0,0 +1,57 @@
import { CONFIGURATION_MESSAGE_PUBLISHER } from '@modules/configuration/configuration.di-tokens';
import { PublishMessageWhenConfigurationIsSetDomainEventHandler } from '@modules/configuration/core/application/event-handlers/publish-message-when-configuration-is-set.domain-event-handler';
import { Domain } from '@modules/configuration/core/domain/configuration.types';
import { ConfigurationSetDomainEvent } from '@modules/configuration/core/domain/events/configuration-set.domain-event';
import { Test, TestingModule } from '@nestjs/testing';
import { CONFIGURATION_SET_ROUTING_KEY } from '@src/app.constants';
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('Publish message when configuration is set domain event handler', () => {
let publishMessageWhenConfigurationIsSetDomainEventHandler: PublishMessageWhenConfigurationIsSetDomainEventHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CONFIGURATION_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
PublishMessageWhenConfigurationIsSetDomainEventHandler,
],
}).compile();
publishMessageWhenConfigurationIsSetDomainEventHandler =
module.get<PublishMessageWhenConfigurationIsSetDomainEventHandler>(
PublishMessageWhenConfigurationIsSetDomainEventHandler,
);
});
it('should publish a message', () => {
jest.spyOn(mockMessagePublisher, 'publish');
const configurationSetDomainEvent: ConfigurationSetDomainEvent = {
id: 'some-domain-event-id',
aggregateId: 'some-aggregate-id',
domain: Domain.AD,
key: 'seatsProposed',
value: '3',
metadata: {
timestamp: new Date('2023-06-28T05:00:00Z').getTime(),
correlationId: 'some-correlation-id',
},
};
publishMessageWhenConfigurationIsSetDomainEventHandler.handle(
configurationSetDomainEvent,
);
expect(
publishMessageWhenConfigurationIsSetDomainEventHandler,
).toBeDefined();
expect(mockMessagePublisher.publish).toHaveBeenCalledTimes(1);
expect(mockMessagePublisher.publish).toHaveBeenCalledWith(
CONFIGURATION_SET_ROUTING_KEY,
'{"id":"some-aggregate-id","metadata":{"correlationId":"some-correlation-id","timestamp":1687928400000},"domain":"AD","key":"seatsProposed","value":"3"}',
);
});
});

View File

@ -0,0 +1,114 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AggregateID, NotFoundException } from '@mobicoop/ddd-library';
import { SetConfigurationRequestDto } from '@modules/configuration/interface/grpc-controllers/dtos/set-configuration.request.dto';
import { Domain } from '@modules/configuration/core/domain/configuration.types';
import { SetConfigurationService } from '@modules/configuration/core/application/commands/set-configuration/set-configuration.service';
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens';
import { SetConfigurationCommand } from '@modules/configuration/core/application/commands/set-configuration/set-configuration.command';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
const setConfigurationRequest: SetConfigurationRequestDto = {
domain: Domain.AD,
key: 'seatsProposed',
value: '3',
};
const existingConfigurationEntity = new ConfigurationEntity({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
props: {
domain: Domain.AD,
key: 'seatsProposed',
value: '2',
},
createdAt: new Date('2023-10-23T07:00:00Z'),
updatedAt: new Date('2023-10-23T07:00:00Z'),
});
const mockConfigurationRepository = {
findOne: jest
.fn()
.mockImplementationOnce(() => {
throw new NotFoundException();
})
.mockImplementationOnce(() => {
throw new NotFoundException();
})
.mockImplementationOnce(() => existingConfigurationEntity)
.mockImplementationOnce(() => existingConfigurationEntity),
insert: jest
.fn()
.mockImplementationOnce(() => ({}))
.mockImplementationOnce(() => {
throw new Error();
}),
update: jest
.fn()
.mockImplementationOnce(() => ({}))
.mockImplementationOnce(() => {
throw new Error();
}),
};
describe('Set Configuration Service', () => {
let setConfigurationService: SetConfigurationService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CONFIGURATION_REPOSITORY,
useValue: mockConfigurationRepository,
},
SetConfigurationService,
],
}).compile();
setConfigurationService = module.get<SetConfigurationService>(
SetConfigurationService,
);
});
it('should be defined', () => {
expect(setConfigurationService).toBeDefined();
});
describe('execution', () => {
const setConfigurationCommand = new SetConfigurationCommand(
setConfigurationRequest,
);
it('should create a new configuration item', async () => {
ConfigurationEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
const result: AggregateID = await setConfigurationService.execute(
setConfigurationCommand,
);
expect(result).toBe('047a6ecf-23d4-4d3e-877c-3225d560a8da');
});
it('should throw an error if something bad happens on configuration item creation', async () => {
ConfigurationEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
await expect(
setConfigurationService.execute(setConfigurationCommand),
).rejects.toBeInstanceOf(Error);
});
it('should update an existing configuration item', async () => {
ConfigurationEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
const result: AggregateID = await setConfigurationService.execute(
setConfigurationCommand,
);
expect(result).toBe('047a6ecf-23d4-4d3e-877c-3225d560a8da');
});
it('should throw an error if something bad happens on configuration item update', async () => {
ConfigurationEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
await expect(
setConfigurationService.execute(setConfigurationCommand),
).rejects.toBeInstanceOf(Error);
});
});
});

View File

@ -1,84 +0,0 @@
import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs';
import { Test, TestingModule } from '@nestjs/testing';
import { Messager } from '../../adapters/secondaries/messager';
import { ConfigurationRepository } from '../../adapters/secondaries/configuration.repository';
import { CreateConfigurationCommand } from '../../commands/create-configuration.command';
import { CreateConfigurationRequest } from '../../domain/dtos/create-configuration.request';
import { Domain } from '../../domain/dtos/domain.enum';
import { Configuration } from '../../domain/entities/configuration';
import { CreateConfigurationUseCase } from '../../domain/usecases/create-configuration.usecase';
import { ConfigurationProfile } from '../../mappers/configuration.profile';
const newConfigurationRequest: CreateConfigurationRequest = {
domain: Domain.USER,
key: 'minAge',
value: '16',
};
const newConfigurationCommand: CreateConfigurationCommand =
new CreateConfigurationCommand(newConfigurationRequest);
const mockConfigurationRepository = {
create: jest
.fn()
.mockImplementationOnce(() => {
return Promise.resolve({
...newConfigurationRequest,
uuid: 'bb281075-1b98-4456-89d6-c643d3044a91',
});
})
.mockImplementation(() => {
throw new Error('Already exists');
}),
};
const mockMessager = {
publish: jest.fn().mockImplementation(),
};
describe('CreateConfigurationUseCase', () => {
let createConfigurationUseCase: CreateConfigurationUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })],
providers: [
{
provide: ConfigurationRepository,
useValue: mockConfigurationRepository,
},
CreateConfigurationUseCase,
ConfigurationProfile,
{
provide: Messager,
useValue: mockMessager,
},
],
}).compile();
createConfigurationUseCase = module.get<CreateConfigurationUseCase>(
CreateConfigurationUseCase,
);
});
it('should be defined', () => {
expect(createConfigurationUseCase).toBeDefined();
});
describe('execute', () => {
it('should create and return a new configuration', async () => {
jest.spyOn(mockMessager, 'publish');
const newConfiguration: Configuration =
await createConfigurationUseCase.execute(newConfigurationCommand);
expect(newConfiguration.key).toBe(newConfigurationRequest.key);
expect(newConfiguration.uuid).toBeDefined();
expect(mockMessager.publish).toHaveBeenCalledTimes(2);
});
it('should throw an error if configuration already exists', async () => {
await expect(
createConfigurationUseCase.execute(newConfigurationCommand),
).rejects.toBeInstanceOf(Error);
});
});
});

View File

@ -1,111 +0,0 @@
import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs';
import { Test, TestingModule } from '@nestjs/testing';
import { ICollection } from 'src/modules/database/interfaces/collection.interface';
import { Messager } from '../../adapters/secondaries/messager';
import { ConfigurationRepository } from '../../adapters/secondaries/configuration.repository';
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: ICollection<Configuration> = {
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.data.forEach((configuration, index) => {
if (configuration.uuid === uuid) {
savedConfiguration = { ...configuration };
mockConfigurations.data.splice(index, 1);
}
});
return savedConfiguration;
})
.mockImplementation(() => {
throw new Error('Error');
}),
};
const mockMessager = {
publish: jest.fn().mockImplementation(),
};
describe('DeleteConfigurationUseCase', () => {
let deleteConfigurationUseCase: DeleteConfigurationUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })],
providers: [
{
provide: ConfigurationRepository,
useValue: mockConfigurationRepository,
},
DeleteConfigurationUseCase,
ConfigurationProfile,
{
provide: Messager,
useValue: mockMessager,
},
],
}).compile();
deleteConfigurationUseCase = module.get<DeleteConfigurationUseCase>(
DeleteConfigurationUseCase,
);
});
it('should be defined', () => {
expect(deleteConfigurationUseCase).toBeDefined();
});
describe('execute', () => {
it('should delete a configuration', async () => {
jest.spyOn(mockMessager, 'publish');
const savedUuid = mockConfigurations.data[0].uuid;
const deleteConfigurationCommand = new DeleteConfigurationCommand(
savedUuid,
);
await deleteConfigurationUseCase.execute(deleteConfigurationCommand);
const deletedConfiguration = mockConfigurations.data.find(
(configuration) => configuration.uuid === savedUuid,
);
expect(deletedConfiguration).toBeUndefined();
expect(mockMessager.publish).toHaveBeenCalledTimes(2);
});
it('should throw an error if configuration does not exist', async () => {
await expect(
deleteConfigurationUseCase.execute(
new DeleteConfigurationCommand('wrong uuid'),
),
).rejects.toBeInstanceOf(Error);
});
});
});

View File

@ -1,83 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ICollection } from 'src/modules/database/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';
const findAllConfigurationsRequest: FindAllConfigurationsRequest =
new FindAllConfigurationsRequest();
findAllConfigurationsRequest.page = 1;
findAllConfigurationsRequest.perPage = 10;
const findAllConfigurationsQuery: FindAllConfigurationsQuery =
new FindAllConfigurationsQuery(findAllConfigurationsRequest);
const mockConfigurations: ICollection<Configuration> = {
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
.mockImplementation((query?: FindAllConfigurationsQuery) => {
return Promise.resolve(mockConfigurations);
}),
};
describe('FindAllConfigurationsUseCase', () => {
let findAllConfigurationsUseCase: FindAllConfigurationsUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: ConfigurationRepository,
useValue: mockConfigurationRepository,
},
FindAllConfigurationsUseCase,
],
}).compile();
findAllConfigurationsUseCase = module.get<FindAllConfigurationsUseCase>(
FindAllConfigurationsUseCase,
);
});
it('should be defined', () => {
expect(findAllConfigurationsUseCase).toBeDefined();
});
describe('execute', () => {
it('should return an array filled with configurations', async () => {
const configurations = await findAllConfigurationsUseCase.execute(
findAllConfigurationsQuery,
);
expect(configurations).toBe(mockConfigurations);
});
});
});

View File

@ -1,84 +0,0 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { Messager } from '../../adapters/secondaries/messager';
import { ConfigurationRepository } from '../../adapters/secondaries/configuration.repository';
import { Domain } from '../../domain/dtos/domain.enum';
import { FindConfigurationByUuidRequest } from '../../domain/dtos/find-configuration-by-uuid.request';
import { FindConfigurationByUuidUseCase } from '../../domain/usecases/find-configuration-by-uuid.usecase';
import { FindConfigurationByUuidQuery } from '../../queries/find-configuration-by-uuid.query';
const mockConfiguration = {
uuid: 'bb281075-1b98-4456-89d6-c643d3044a91',
domain: Domain.USER,
key: 'key1',
value: 'value1',
};
const mockConfigurationRepository = {
findOneByUuid: jest
.fn()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((query?: FindConfigurationByUuidQuery) => {
return Promise.resolve(mockConfiguration);
})
.mockImplementation(() => {
return Promise.resolve(undefined);
}),
};
const mockMessager = {
publish: jest.fn().mockImplementation(),
};
describe('FindConfigurationByUuidUseCase', () => {
let findConfigurationByUuidUseCase: FindConfigurationByUuidUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
{
provide: ConfigurationRepository,
useValue: mockConfigurationRepository,
},
FindConfigurationByUuidUseCase,
{
provide: Messager,
useValue: mockMessager,
},
],
}).compile();
findConfigurationByUuidUseCase = module.get<FindConfigurationByUuidUseCase>(
FindConfigurationByUuidUseCase,
);
});
it('should be defined', () => {
expect(findConfigurationByUuidUseCase).toBeDefined();
});
describe('execute', () => {
it('should return a Configuration', async () => {
const findConfigurationByUuidRequest: FindConfigurationByUuidRequest =
new FindConfigurationByUuidRequest();
findConfigurationByUuidRequest.uuid =
'bb281075-1b98-4456-89d6-c643d3044a91';
const Configuration = await findConfigurationByUuidUseCase.execute(
new FindConfigurationByUuidQuery(findConfigurationByUuidRequest),
);
expect(Configuration).toBe(mockConfiguration);
});
it('should throw an error if Configuration does not exist', async () => {
const findConfigurationByUuidRequest: FindConfigurationByUuidRequest =
new FindConfigurationByUuidRequest();
findConfigurationByUuidRequest.uuid =
'bb281075-1b98-4456-89d6-c643d3044a90';
await expect(
findConfigurationByUuidUseCase.execute(
new FindConfigurationByUuidQuery(findConfigurationByUuidRequest),
),
).rejects.toBeInstanceOf(NotFoundException);
});
});
});

View File

@ -0,0 +1,36 @@
import { ConfigurationMapper } from '@modules/configuration/configuration.mapper';
import { ConfigurationRepository } from '@modules/configuration/infrastructure/configuration.repository';
import { PrismaService } from '@modules/configuration/infrastructure/prisma.service';
import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter';
import { Test, TestingModule } from '@nestjs/testing';
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('Configuration repository', () => {
let prismaService: PrismaService;
let configurationMapper: ConfigurationMapper;
let eventEmitter: EventEmitter2;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [EventEmitterModule.forRoot()],
providers: [PrismaService, ConfigurationMapper],
}).compile();
prismaService = module.get<PrismaService>(PrismaService);
configurationMapper = module.get<ConfigurationMapper>(ConfigurationMapper);
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
});
it('should be defined', () => {
expect(
new ConfigurationRepository(
prismaService,
configurationMapper,
eventEmitter,
mockMessagePublisher,
),
).toBeDefined();
});
});

View File

@ -0,0 +1,108 @@
import {
DatabaseErrorException,
NotFoundException,
} from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { Domain } from '@modules/configuration/core/domain/configuration.types';
import { DeleteConfigurationGrpcController } from '@modules/configuration/interface/grpc-controllers/delete-configuration.grpc.controller';
import { DeleteConfigurationRequestDto } from '@modules/configuration/interface/grpc-controllers/dtos/delete-configuration.request.dto';
import { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
const deleteConfigurationRequest: DeleteConfigurationRequestDto = {
domain: Domain.AD,
key: 'seatsProposed',
};
const mockCommandBus = {
execute: jest
.fn()
.mockImplementationOnce(() => ({}))
.mockImplementationOnce(() => {
throw new NotFoundException();
})
.mockImplementationOnce(() => {
throw new DatabaseErrorException();
})
.mockImplementationOnce(() => {
throw new Error();
}),
};
describe('Delete Configuration Grpc Controller', () => {
let deleteConfigurationGrpcController: DeleteConfigurationGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
DeleteConfigurationGrpcController,
],
}).compile();
deleteConfigurationGrpcController =
module.get<DeleteConfigurationGrpcController>(
DeleteConfigurationGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(deleteConfigurationGrpcController).toBeDefined();
});
it('should delete a configuration item', async () => {
jest.spyOn(mockCommandBus, 'execute');
await deleteConfigurationGrpcController.delete(deleteConfigurationRequest);
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if configuration item does not exist', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await deleteConfigurationGrpcController.delete(
deleteConfigurationRequest,
);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.NOT_FOUND);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if a database error occurs', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await deleteConfigurationGrpcController.delete(
deleteConfigurationRequest,
);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.INTERNAL);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a generic RpcException', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await deleteConfigurationGrpcController.delete(
deleteConfigurationRequest,
);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,106 @@
import { NotFoundException } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { ConfigurationMapper } from '@modules/configuration/configuration.mapper';
import { Domain } from '@modules/configuration/core/domain/configuration.types';
import { GetConfigurationGrpcController } from '@modules/configuration/interface/grpc-controllers/get-configuration.grpc.controller';
import { QueryBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
const mockQueryBus = {
execute: jest
.fn()
.mockImplementationOnce(() => '200d61a8-d878-4378-a609-c19ea71633d2')
.mockImplementationOnce(() => {
throw new NotFoundException();
})
.mockImplementationOnce(() => {
throw new Error();
}),
};
const mockConfigurationMapper = {
toResponse: jest.fn().mockImplementationOnce(() => ({
domain: Domain.AD,
key: 'seatsProposed',
value: '3',
})),
};
describe('Get Configuration Grpc Controller', () => {
let getConfigurationGrpcController: GetConfigurationGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: QueryBus,
useValue: mockQueryBus,
},
{
provide: ConfigurationMapper,
useValue: mockConfigurationMapper,
},
GetConfigurationGrpcController,
],
}).compile();
getConfigurationGrpcController = module.get<GetConfigurationGrpcController>(
GetConfigurationGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(getConfigurationGrpcController).toBeDefined();
});
it('should return a configuration item', async () => {
jest.spyOn(mockQueryBus, 'execute');
jest.spyOn(mockConfigurationMapper, 'toResponse');
const response = await getConfigurationGrpcController.get({
domain: Domain.AD,
key: 'seatsProposed',
});
expect(response.value).toBe('3');
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
expect(mockConfigurationMapper.toResponse).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if configuration is not found', async () => {
jest.spyOn(mockQueryBus, 'execute');
jest.spyOn(mockConfigurationMapper, 'toResponse');
expect.assertions(4);
try {
await getConfigurationGrpcController.get({
domain: Domain.AD,
key: 'price',
});
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.NOT_FOUND);
}
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
expect(mockConfigurationMapper.toResponse).toHaveBeenCalledTimes(0);
});
it('should throw a generic RpcException', async () => {
jest.spyOn(mockQueryBus, 'execute');
jest.spyOn(mockConfigurationMapper, 'toResponse');
expect.assertions(4);
try {
await getConfigurationGrpcController.get({
domain: Domain.AD,
key: 'someValue',
});
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
expect(mockConfigurationMapper.toResponse).toHaveBeenCalledTimes(0);
});
});

View File

@ -0,0 +1,60 @@
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { PropagateConfigurationsGrpcController } from '@modules/configuration/interface/grpc-controllers/propagate-configurations.grpc.controller';
import { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
const mockCommandBus = {
execute: jest
.fn()
.mockImplementationOnce(() => {})
.mockImplementationOnce(() => {
throw new Error();
}),
};
describe('Propagate Configurations Grpc Controller', () => {
let propagateConfigurationsGrpcController: PropagateConfigurationsGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
PropagateConfigurationsGrpcController,
],
}).compile();
propagateConfigurationsGrpcController =
module.get<PropagateConfigurationsGrpcController>(
PropagateConfigurationsGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(propagateConfigurationsGrpcController).toBeDefined();
});
it('should propagate configuration items', async () => {
jest.spyOn(mockCommandBus, 'execute');
await propagateConfigurationsGrpcController.propagate();
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a generic RpcException', async () => {
jest.spyOn(mockCommandBus, 'execute');
try {
await propagateConfigurationsGrpcController.propagate();
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,73 @@
import { IdResponse } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { Domain } from '@modules/configuration/core/domain/configuration.types';
import { SetConfigurationRequestDto } from '@modules/configuration/interface/grpc-controllers/dtos/set-configuration.request.dto';
import { SetConfigurationGrpcController } from '@modules/configuration/interface/grpc-controllers/set-configuration.grpc.controller';
import { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
const setConfigurationRequest: SetConfigurationRequestDto = {
domain: Domain.AD,
key: 'seatsProposed',
value: '3',
};
const mockCommandBus = {
execute: jest
.fn()
.mockImplementationOnce(() => '200d61a8-d878-4378-a609-c19ea71633d2')
.mockImplementationOnce(() => {
throw new Error();
}),
};
describe('Set Configuration Grpc Controller', () => {
let setConfigurationGrpcController: SetConfigurationGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
SetConfigurationGrpcController,
],
}).compile();
setConfigurationGrpcController = module.get<SetConfigurationGrpcController>(
SetConfigurationGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(setConfigurationGrpcController).toBeDefined();
});
it('should set a configuration item', async () => {
jest.spyOn(mockCommandBus, 'execute');
const result: IdResponse = await setConfigurationGrpcController.set(
setConfigurationRequest,
);
expect(result).toBeInstanceOf(IdResponse);
expect(result.id).toBe('200d61a8-d878-4378-a609-c19ea71633d2');
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a generic RpcException', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await setConfigurationGrpcController.set(setConfigurationRequest);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,47 +0,0 @@
import { AmqpConnection } from '@golevelup/nestjs-rabbitmq';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { Messager } from '../../adapters/secondaries/messager';
const mockAmqpConnection = {
publish: jest.fn().mockImplementation(),
};
const mockConfigService = {
get: jest.fn().mockResolvedValue({
RMQ_EXCHANGE: 'mobicoop',
}),
};
describe('Messager', () => {
let messager: Messager;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
Messager,
{
provide: AmqpConnection,
useValue: mockAmqpConnection,
},
{
provide: ConfigService,
useValue: mockConfigService,
},
],
}).compile();
messager = module.get<Messager>(Messager);
});
it('should be defined', () => {
expect(messager).toBeDefined();
});
it('should publish a message', async () => {
jest.spyOn(mockAmqpConnection, 'publish');
messager.publish('configuration.create.info', 'my-test');
expect(mockAmqpConnection.publish).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,100 +0,0 @@
import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs';
import { Test, TestingModule } from '@nestjs/testing';
import { ICollection } from 'src/modules/database/interfaces/collection.interface';
import { Messager } from '../../adapters/secondaries/messager';
import { ConfigurationRepository } from '../../adapters/secondaries/configuration.repository';
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<Configuration> = {
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 mockMessager = {
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: Messager,
useValue: mockMessager,
},
PropagateConfigurationsUseCase,
ConfigurationProfile,
],
}).compile();
propagateConfigurationsUseCase = module.get<PropagateConfigurationsUseCase>(
PropagateConfigurationsUseCase,
);
});
it('should be defined', () => {
expect(propagateConfigurationsUseCase).toBeDefined();
});
describe('execute', () => {
it('should propagate configurations', async () => {
jest.spyOn(mockMessager, 'publish');
await propagateConfigurationsUseCase.execute(
propagateConfigurationsQuery,
);
expect(mockMessager.publish).toHaveBeenCalledTimes(2);
});
it('should throw an error if repository call fails', async () => {
await expect(
propagateConfigurationsUseCase.execute(propagateConfigurationsQuery),
).rejects.toBeInstanceOf(Error);
});
});
});

View File

@ -1,88 +0,0 @@
import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs';
import { Test, TestingModule } from '@nestjs/testing';
import { Messager } from '../../adapters/secondaries/messager';
import { ConfigurationRepository } from '../../adapters/secondaries/configuration.repository';
import { UpdateConfigurationCommand } from '../../commands/update-configuration.command';
import { Domain } from '../../domain/dtos/domain.enum';
import { UpdateConfigurationRequest } from '../../domain/dtos/update-configuration.request';
import { Configuration } from '../../domain/entities/configuration';
import { UpdateConfigurationUseCase } from '../../domain/usecases/update-configuration.usecase';
import { ConfigurationProfile } from '../../mappers/configuration.profile';
const originalConfiguration: Configuration = new Configuration();
originalConfiguration.uuid = 'bb281075-1b98-4456-89d6-c643d3044a91';
originalConfiguration.domain = Domain.USER;
originalConfiguration.key = 'key1';
originalConfiguration.value = 'value1';
const updateConfigurationRequest: UpdateConfigurationRequest = {
uuid: 'bb281075-1b98-4456-89d6-c643d3044a91',
value: 'value2',
};
const updateConfigurationCommand: UpdateConfigurationCommand =
new UpdateConfigurationCommand(updateConfigurationRequest);
const mockConfigurationRepository = {
update: jest
.fn()
.mockImplementationOnce((uuid: string, params: any) => {
originalConfiguration.value = params.value;
return Promise.resolve(originalConfiguration);
})
.mockImplementation(() => {
throw new Error('Error');
}),
};
const mockMessager = {
publish: jest.fn().mockImplementation(),
};
describe('UpdateConfigurationUseCase', () => {
let updateConfigurationUseCase: UpdateConfigurationUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })],
providers: [
{
provide: ConfigurationRepository,
useValue: mockConfigurationRepository,
},
UpdateConfigurationUseCase,
ConfigurationProfile,
{
provide: Messager,
useValue: mockMessager,
},
],
}).compile();
updateConfigurationUseCase = module.get<UpdateConfigurationUseCase>(
UpdateConfigurationUseCase,
);
});
it('should be defined', () => {
expect(updateConfigurationUseCase).toBeDefined();
});
describe('execute', () => {
it('should update a configuration value', async () => {
jest.spyOn(mockMessager, 'publish');
const updatedConfiguration: Configuration =
await updateConfigurationUseCase.execute(updateConfigurationCommand);
expect(updatedConfiguration.value).toBe(updateConfigurationRequest.value);
expect(mockMessager.publish).toHaveBeenCalledTimes(2);
});
it('should throw an error if configuration does not exist', async () => {
await expect(
updateConfigurationUseCase.execute(updateConfigurationCommand),
).rejects.toBeInstanceOf(Error);
});
});
});

View File

@ -1,259 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { DatabaseException } from '../../exceptions/database.exception';
import { ICollection } from '../../interfaces/collection.interface';
import { IRepository } from '../../interfaces/repository.interface';
import { PrismaService } from './prisma-service';
/**
* Child classes MUST redefined _model property with appropriate model name
*/
@Injectable()
export abstract class PrismaRepository<T> implements IRepository<T> {
protected model: string;
constructor(protected readonly prisma: PrismaService) {}
findAll = async (
page = 1,
perPage = 10,
where?: any,
include?: any,
): Promise<ICollection<T>> => {
const [data, total] = await this.prisma.$transaction([
this.prisma[this.model].findMany({
where,
include,
skip: (page - 1) * perPage,
take: perPage,
}),
this.prisma[this.model].count({
where,
}),
]);
return Promise.resolve({
data,
total,
});
};
findOneByUuid = async (uuid: string): Promise<T> => {
try {
const entity = await this.prisma[this.model].findUnique({
where: { uuid },
});
return entity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
findOne = async (where: any, include?: any): Promise<T> => {
try {
const entity = await this.prisma[this.model].findFirst({
where: where,
include: include,
});
return entity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
);
} else {
throw new DatabaseException();
}
}
};
// TODO : using any is not good, but needed for nested entities
// TODO : Refactor for good clean architecture ?
create = async (entity: Partial<T> | any, include?: any): Promise<T> => {
try {
const res = await this.prisma[this.model].create({
data: entity,
include: include,
});
return res;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
update = async (uuid: string, entity: Partial<T>): Promise<T> => {
try {
const updatedEntity = await this.prisma[this.model].update({
where: { uuid },
data: entity,
});
return updatedEntity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
updateWhere = async (
where: any,
entity: Partial<T> | any,
include?: any,
): Promise<T> => {
try {
const updatedEntity = await this.prisma[this.model].update({
where: where,
data: entity,
include: include,
});
return updatedEntity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
delete = async (uuid: string): Promise<T> => {
try {
const entity = await this.prisma[this.model].delete({
where: { uuid },
});
return entity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
deleteMany = async (where: any): Promise<void> => {
try {
const entity = await this.prisma[this.model].deleteMany({
where: where,
});
return entity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
findAllByQuery = async (
include: string[],
where: string[],
): Promise<ICollection<T>> => {
const query = `SELECT ${include.join(',')} FROM ${
this.model
} WHERE ${where.join(' AND ')}`;
const data: T[] = await this.prisma.$queryRawUnsafe(query);
return Promise.resolve({
data,
total: data.length,
});
};
createWithFields = async (fields: object): Promise<number> => {
try {
const command = `INSERT INTO ${this.model} ("${Object.keys(fields).join(
'","',
)}") VALUES (${Object.values(fields).join(',')})`;
return await this.prisma.$executeRawUnsafe(command);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
updateWithFields = async (uuid: string, entity: object): Promise<number> => {
entity['"updatedAt"'] = `to_timestamp(${Date.now()} / 1000.0)`;
const values = Object.keys(entity).map((key) => `${key} = ${entity[key]}`);
try {
const command = `UPDATE ${this.model} SET ${values.join(
', ',
)} WHERE uuid = '${uuid}'`;
return await this.prisma.$executeRawUnsafe(command);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
healthCheck = async (): Promise<boolean> => {
try {
await this.prisma.$queryRaw`SELECT 1`;
return true;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
}

View File

@ -1,15 +0,0 @@
import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
async enableShutdownHooks(app: INestApplication) {
this.$on('beforeExit', async () => {
await app.close();
});
}
}

View File

@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { PrismaService } from './adapters/secondaries/prisma-service';
import { ConfigRepository } from './domain/configuration.repository';
@Module({
providers: [PrismaService, ConfigRepository],
exports: [PrismaService, ConfigRepository],
})
export class DatabaseModule {}

View File

@ -1,3 +0,0 @@
import { PrismaRepository } from '../adapters/secondaries/prisma-repository.abstract';
export class ConfigRepository<T> extends PrismaRepository<T> {}

View File

@ -1,24 +0,0 @@
export class DatabaseException implements Error {
name: string;
message: string;
constructor(
private _type: string = 'unknown',
private _code: string = '',
message?: string,
) {
this.name = 'DatabaseException';
this.message = message ?? 'An error occured with the database.';
if (this.message.includes('Unique constraint failed')) {
this.message = 'Already exists.';
}
}
get type(): string {
return this._type;
}
get code(): string {
return this._code;
}
}

View File

@ -1,4 +0,0 @@
export interface ICollection<T> {
data: T[];
total: number;
}

View File

@ -1,17 +0,0 @@
import { ICollection } from './collection.interface';
export interface IRepository<T> {
findAll(
page: number,
perPage: number,
params?: any,
include?: any,
): Promise<ICollection<T>>;
findOne(where: any, include?: any): Promise<T>;
findOneByUuid(uuid: string, include?: any): Promise<T>;
create(entity: Partial<T> | any, include?: any): Promise<T>;
update(uuid: string, entity: Partial<T>, include?: any): Promise<T>;
updateWhere(where: any, entity: Partial<T> | any, include?: any): Promise<T>;
delete(uuid: string): Promise<T>;
deleteMany(where: any): Promise<void>;
}

View File

@ -1,571 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaService } from '../../adapters/secondaries/prisma-service';
import { PrismaRepository } from '../../adapters/secondaries/prisma-repository.abstract';
import { DatabaseException } from '../../exceptions/database.exception';
import { Prisma } from '@prisma/client';
class FakeEntity {
uuid?: string;
name: string;
}
let entityId = 2;
const entityUuid = 'uuid-';
const entityName = 'name-';
const createRandomEntity = (): FakeEntity => {
const entity: FakeEntity = {
uuid: `${entityUuid}${entityId}`,
name: `${entityName}${entityId}`,
};
entityId++;
return entity;
};
const fakeEntityToCreate: FakeEntity = {
name: 'test',
};
const fakeEntityCreated: FakeEntity = {
...fakeEntityToCreate,
uuid: 'some-uuid',
};
const fakeEntities: FakeEntity[] = [];
Array.from({ length: 10 }).forEach(() => {
fakeEntities.push(createRandomEntity());
});
@Injectable()
class FakePrismaRepository extends PrismaRepository<FakeEntity> {
protected model = 'fake';
}
class FakePrismaService extends PrismaService {
fake: any;
}
const mockPrismaService = {
$transaction: jest.fn().mockImplementation(async (data: any) => {
const entities = await data[0];
if (entities.length == 1) {
return Promise.resolve([[fakeEntityCreated], 1]);
}
return Promise.resolve([fakeEntities, fakeEntities.length]);
}),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
$queryRawUnsafe: jest.fn().mockImplementation((query?: string) => {
return Promise.resolve(fakeEntities);
}),
$executeRawUnsafe: jest
.fn()
.mockResolvedValueOnce(fakeEntityCreated)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((fields: object) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((fields: object) => {
throw new Error('an unknown error');
})
.mockResolvedValueOnce(fakeEntityCreated)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((fields: object) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((fields: object) => {
throw new Error('an unknown error');
}),
$queryRaw: jest
.fn()
.mockImplementationOnce(() => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
.mockImplementationOnce(() => {
return true;
})
.mockImplementation(() => {
throw new Prisma.PrismaClientKnownRequestError('Database unavailable', {
code: 'code',
clientVersion: 'version',
});
}),
fake: {
create: jest
.fn()
.mockResolvedValueOnce(fakeEntityCreated)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Error('an unknown error');
}),
findMany: jest.fn().mockImplementation((params?: any) => {
if (params?.where?.limit == 1) {
return Promise.resolve([fakeEntityCreated]);
}
return Promise.resolve(fakeEntities);
}),
count: jest.fn().mockResolvedValue(fakeEntities.length),
findUnique: jest.fn().mockImplementation(async (params?: any) => {
let entity;
if (params?.where?.uuid) {
entity = fakeEntities.find(
(entity) => entity.uuid === params?.where?.uuid,
);
}
if (!entity && params?.where?.uuid == 'unknown') {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
} else if (!entity) {
throw new Error('no entity');
}
return entity;
}),
findFirst: jest
.fn()
.mockImplementationOnce((params?: any) => {
if (params?.where?.name) {
return Promise.resolve(
fakeEntities.find((entity) => entity.name === params?.where?.name),
);
}
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Error('an unknown error');
}),
update: jest
.fn()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
.mockImplementationOnce((params: any) => {
const entity = fakeEntities.find(
(entity) => entity.name === params.where.name,
);
Object.entries(params.data).map(([key, value]) => {
entity[key] = value;
});
return Promise.resolve(entity);
})
.mockImplementation((params: any) => {
const entity = fakeEntities.find(
(entity) => entity.uuid === params.where.uuid,
);
Object.entries(params.data).map(([key, value]) => {
entity[key] = value;
});
return Promise.resolve(entity);
}),
delete: jest
.fn()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
.mockImplementation((params: any) => {
let found = false;
fakeEntities.forEach((entity, index) => {
if (entity.uuid === params?.where?.uuid) {
found = true;
fakeEntities.splice(index, 1);
}
});
if (!found) {
throw new Error();
}
}),
deleteMany: jest
.fn()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
.mockImplementation((params: any) => {
let found = false;
fakeEntities.forEach((entity, index) => {
if (entity.uuid === params?.where?.uuid) {
found = true;
fakeEntities.splice(index, 1);
}
});
if (!found) {
throw new Error();
}
}),
},
};
describe('PrismaRepository', () => {
let fakeRepository: FakePrismaRepository;
let prisma: FakePrismaService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
FakePrismaRepository,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
fakeRepository = module.get<FakePrismaRepository>(FakePrismaRepository);
prisma = module.get<PrismaService>(PrismaService) as FakePrismaService;
});
it('should be defined', () => {
expect(fakeRepository).toBeDefined();
expect(prisma).toBeDefined();
});
describe('findAll', () => {
it('should return an array of entities', async () => {
jest.spyOn(prisma.fake, 'findMany');
jest.spyOn(prisma.fake, 'count');
jest.spyOn(prisma, '$transaction');
const entities = await fakeRepository.findAll();
expect(entities).toStrictEqual({
data: fakeEntities,
total: fakeEntities.length,
});
});
it('should return an array containing only one entity', async () => {
const entities = await fakeRepository.findAll(1, 10, { limit: 1 });
expect(prisma.fake.findMany).toHaveBeenCalledWith({
skip: 0,
take: 10,
where: { limit: 1 },
});
expect(entities).toEqual({
data: [fakeEntityCreated],
total: 1,
});
});
});
describe('create', () => {
it('should create an entity', async () => {
jest.spyOn(prisma.fake, 'create');
const newEntity = await fakeRepository.create(fakeEntityToCreate);
expect(newEntity).toBe(fakeEntityCreated);
expect(prisma.fake.create).toHaveBeenCalledTimes(1);
});
it('should throw a DatabaseException for client error', async () => {
await expect(
fakeRepository.create(fakeEntityToCreate),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should throw a DatabaseException if uuid is not found', async () => {
await expect(
fakeRepository.create(fakeEntityToCreate),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('findOneByUuid', () => {
it('should find an entity by uuid', async () => {
const entity = await fakeRepository.findOneByUuid(fakeEntities[0].uuid);
expect(entity).toBe(fakeEntities[0]);
});
it('should throw a DatabaseException for client error', async () => {
await expect(
fakeRepository.findOneByUuid('unknown'),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should throw a DatabaseException if uuid is not found', async () => {
await expect(
fakeRepository.findOneByUuid('wrong-uuid'),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('findOne', () => {
it('should find one entity', async () => {
const entity = await fakeRepository.findOne({
name: fakeEntities[0].name,
});
expect(entity.name).toBe(fakeEntities[0].name);
});
it('should throw a DatabaseException for client error', async () => {
await expect(
fakeRepository.findOne({
name: fakeEntities[0].name,
}),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should throw a DatabaseException for unknown error', async () => {
await expect(
fakeRepository.findOne({
name: fakeEntities[0].name,
}),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('update', () => {
it('should throw a DatabaseException for client error', async () => {
await expect(
fakeRepository.update('fake-uuid', { name: 'error' }),
).rejects.toBeInstanceOf(DatabaseException);
await expect(
fakeRepository.updateWhere({ name: 'error' }, { name: 'new error' }),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should update an entity with name', async () => {
const newName = 'new-random-name';
await fakeRepository.updateWhere(
{ name: fakeEntities[0].name },
{
name: newName,
},
);
expect(fakeEntities[0].name).toBe(newName);
});
it('should update an entity with uuid', async () => {
const newName = 'random-name';
await fakeRepository.update(fakeEntities[0].uuid, {
name: newName,
});
expect(fakeEntities[0].name).toBe(newName);
});
it("should throw an exception if an entity doesn't exist", async () => {
await expect(
fakeRepository.update('fake-uuid', { name: 'error' }),
).rejects.toBeInstanceOf(DatabaseException);
await expect(
fakeRepository.updateWhere({ name: 'error' }, { name: 'new error' }),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('delete', () => {
it('should throw a DatabaseException for client error', async () => {
await expect(fakeRepository.delete('fake-uuid')).rejects.toBeInstanceOf(
DatabaseException,
);
});
it('should delete an entity', async () => {
const savedUuid = fakeEntities[0].uuid;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const res = await fakeRepository.delete(savedUuid);
const deletedEntity = fakeEntities.find(
(entity) => entity.uuid === savedUuid,
);
expect(deletedEntity).toBeUndefined();
});
it("should throw an exception if an entity doesn't exist", async () => {
await expect(fakeRepository.delete('fake-uuid')).rejects.toBeInstanceOf(
DatabaseException,
);
});
});
describe('deleteMany', () => {
it('should throw a DatabaseException for client error', async () => {
await expect(
fakeRepository.deleteMany({ uuid: 'fake-uuid' }),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should delete entities based on their uuid', async () => {
const savedUuid = fakeEntities[0].uuid;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const res = await fakeRepository.deleteMany({ uuid: savedUuid });
const deletedEntity = fakeEntities.find(
(entity) => entity.uuid === savedUuid,
);
expect(deletedEntity).toBeUndefined();
});
it("should throw an exception if an entity doesn't exist", async () => {
await expect(
fakeRepository.deleteMany({ uuid: 'fake-uuid' }),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('findAllByquery', () => {
it('should return an array of entities', async () => {
const entities = await fakeRepository.findAllByQuery(
['uuid', 'name'],
['name is not null'],
);
expect(entities).toStrictEqual({
data: fakeEntities,
total: fakeEntities.length,
});
});
});
describe('createWithFields', () => {
it('should create an entity', async () => {
jest.spyOn(prisma, '$queryRawUnsafe');
const newEntity = await fakeRepository.createWithFields({
uuid: '804319b3-a09b-4491-9f82-7976bfce0aff',
name: 'my-name',
});
expect(newEntity).toBe(fakeEntityCreated);
expect(prisma.$queryRawUnsafe).toHaveBeenCalledTimes(1);
});
it('should throw a DatabaseException for client error', async () => {
await expect(
fakeRepository.createWithFields({
uuid: '804319b3-a09b-4491-9f82-7976bfce0aff',
name: 'my-name',
}),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should throw a DatabaseException if uuid is not found', async () => {
await expect(
fakeRepository.createWithFields({
name: 'my-name',
}),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('updateWithFields', () => {
it('should update an entity', async () => {
jest.spyOn(prisma, '$queryRawUnsafe');
const updatedEntity = await fakeRepository.updateWithFields(
'804319b3-a09b-4491-9f82-7976bfce0aff',
{
name: 'my-name',
},
);
expect(updatedEntity).toBe(fakeEntityCreated);
expect(prisma.$queryRawUnsafe).toHaveBeenCalledTimes(1);
});
it('should throw a DatabaseException for client error', async () => {
await expect(
fakeRepository.updateWithFields(
'804319b3-a09b-4491-9f82-7976bfce0aff',
{
name: 'my-name',
},
),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should throw a DatabaseException if uuid is not found', async () => {
await expect(
fakeRepository.updateWithFields(
'804319b3-a09b-4491-9f82-7976bfce0aff',
{
name: 'my-name',
},
),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('healthCheck', () => {
it('should throw a DatabaseException for client error', async () => {
await expect(fakeRepository.healthCheck()).rejects.toBeInstanceOf(
DatabaseException,
);
});
it('should return a healthy result', async () => {
const res = await fakeRepository.healthCheck();
expect(res).toBeTruthy();
});
it('should throw an exception if database is not available', async () => {
await expect(fakeRepository.healthCheck()).rejects.toBeInstanceOf(
DatabaseException,
);
});
});
});

View File

@ -1,42 +0,0 @@
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase';
enum ServingStatus {
UNKNOWN = 0,
SERVING = 1,
NOT_SERVING = 2,
}
interface HealthCheckRequest {
service: string;
}
interface HealthCheckResponse {
status: ServingStatus;
}
@Controller()
export class HealthServerController {
constructor(
private readonly prismaHealthIndicatorUseCase: PrismaHealthIndicatorUseCase,
) {}
@GrpcMethod('Health', 'Check')
async check(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
data: HealthCheckRequest,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
metadata: any,
): Promise<HealthCheckResponse> {
const healthCheck = await this.prismaHealthIndicatorUseCase.isHealthy(
'prisma',
);
return {
status:
healthCheck['prisma'].status == 'up'
? ServingStatus.SERVING
: ServingStatus.NOT_SERVING,
};
}
}

View File

@ -1,34 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import {
HealthCheckService,
HealthCheck,
HealthCheckResult,
} from '@nestjs/terminus';
import { Messager } from '../secondaries/messager';
import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase';
@Controller('health')
export class HealthController {
constructor(
private readonly prismaHealthIndicatorUseCase: PrismaHealthIndicatorUseCase,
private readonly healthCheckService: HealthCheckService,
private readonly messager: Messager,
) {}
@Get()
@HealthCheck()
async check() {
try {
return await this.healthCheckService.check([
async () => this.prismaHealthIndicatorUseCase.isHealthy('prisma'),
]);
} catch (error) {
const healthCheckResult: HealthCheckResult = error.response;
this.messager.publish(
'logging.configuration.health.crit',
JSON.stringify(healthCheckResult.error),
);
throw error;
}
}
}

View File

@ -1,12 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export abstract class IMessageBroker {
exchange: string;
constructor(exchange: string) {
this.exchange = exchange;
}
abstract publish(routingKey: string, message: string): void;
}

View File

@ -1,18 +0,0 @@
import { AmqpConnection } from '@golevelup/nestjs-rabbitmq';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IMessageBroker } from './message-broker';
@Injectable()
export class Messager extends IMessageBroker {
constructor(
private readonly amqpConnection: AmqpConnection,
configService: ConfigService,
) {
super(configService.get<string>('RMQ_EXCHANGE'));
}
publish = (routingKey: string, message: string): void => {
this.amqpConnection.publish(this.exchange, routingKey, message);
};
}

View File

@ -1,25 +0,0 @@
import { Injectable } from '@nestjs/common';
import {
HealthCheckError,
HealthIndicator,
HealthIndicatorResult,
} from '@nestjs/terminus';
import { ConfigurationRepository } from '../../../configuration/adapters/secondaries/configuration.repository';
@Injectable()
export class PrismaHealthIndicatorUseCase extends HealthIndicator {
constructor(private readonly repository: ConfigurationRepository) {
super();
}
isHealthy = async (key: string): Promise<HealthIndicatorResult> => {
try {
await this.repository.healthCheck();
return this.getStatus(key, true);
} catch (e) {
throw new HealthCheckError('Prisma', {
prisma: e.message,
});
}
};
}

Some files were not shown because too many files have changed in this diff Show More