switch to redis

This commit is contained in:
sbriat 2023-10-24 15:47:11 +02:00
parent f960299565
commit 3505d2a520
51 changed files with 470 additions and 1361 deletions

View File

@ -3,14 +3,15 @@ SERVICE_URL=0.0.0.0
SERVICE_PORT=5003 SERVICE_PORT=5003
HEALTH_SERVICE_PORT=6003 HEALTH_SERVICE_PORT=6003
# PRISMA
DATABASE_URL="postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=configuration"
# MESSAGE BROKER # MESSAGE BROKER
MESSAGE_BROKER_URI=amqp://v3-broker:5672 MESSAGE_BROKER_URI=amqp://v3-broker:5672
MESSAGE_BROKER_EXCHANGE=mobicoop MESSAGE_BROKER_EXCHANGE=mobicoop
MESSAGE_BROKER_EXCHANGE_DURABILITY=true MESSAGE_BROKER_EXCHANGE_DURABILITY=true
# REDIS
REDIS_HOST=v3-redis
REDIS_PASSWORD=redis
REDIS_PORT=6379
# DEFAULT CONFIGURATION # DEFAULT CONFIGURATION

107
package-lock.json generated
View File

@ -23,8 +23,10 @@
"@nestjs/platform-express": "^10.2.7", "@nestjs/platform-express": "^10.2.7",
"@nestjs/terminus": "^10.1.1", "@nestjs/terminus": "^10.1.1",
"@prisma/client": "^5.4.2", "@prisma/client": "^5.4.2",
"@songkeys/nestjs-redis": "^10.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"ioredis": "^5.3.2",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^5.0.5", "rimraf": "^5.0.5",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
@ -1096,6 +1098,11 @@
"integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
"dev": true "dev": true
}, },
"node_modules/@ioredis/commands": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz",
"integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg=="
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -2490,6 +2497,27 @@
"@sinonjs/commons": "^3.0.0" "@sinonjs/commons": "^3.0.0"
} }
}, },
"node_modules/@songkeys/nestjs-redis": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@songkeys/nestjs-redis/-/nestjs-redis-10.0.0.tgz",
"integrity": "sha512-s56+NECuJXzcaPLYzpvA2xjL0e/1Zy55UE0q6b1UqpbQSKI06TFPFCWCMUadJigiuB26O1hxi+lmDbzahKvcLg==",
"dependencies": {
"tslib": "2.6.0"
},
"engines": {
"node": ">=16.0.0"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"ioredis": "^5.0.0"
}
},
"node_modules/@songkeys/nestjs-redis/node_modules/tslib": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz",
"integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA=="
},
"node_modules/@tsconfig/node10": { "node_modules/@tsconfig/node10": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
@ -2687,12 +2715,6 @@
"pretty-format": "^29.0.0" "pretty-format": "^29.0.0"
} }
}, },
"node_modules/@types/js-yaml": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.8.tgz",
"integrity": "sha512-m6jnPk1VhlYRiLFm3f8X9Uep761f+CK8mHyS65LutH2OhmBF0BeMEjHgg05usH8PLZMWWc/BUR9RPmkvpWnyRA==",
"dev": true
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.14", "version": "7.0.14",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.14.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.14.tgz",
@ -4104,6 +4126,14 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/co": { "node_modules/co": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@ -4587,6 +4617,14 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -5944,6 +5982,29 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/ioredis": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz",
"integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==",
"dependencies": {
"@ioredis/commands": "^1.1.1",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -6971,6 +7032,16 @@
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="
}, },
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="
},
"node_modules/lodash.memoize": { "node_modules/lodash.memoize": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@ -8014,6 +8085,25 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/reflect-metadata": { "node_modules/reflect-metadata": {
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz",
@ -8538,6 +8628,11 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",

View File

@ -45,8 +45,10 @@
"@nestjs/platform-express": "^10.2.7", "@nestjs/platform-express": "^10.2.7",
"@nestjs/terminus": "^10.1.1", "@nestjs/terminus": "^10.1.1",
"@prisma/client": "^5.4.2", "@prisma/client": "^5.4.2",
"@songkeys/nestjs-redis": "^10.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"ioredis": "^5.3.2",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^5.0.5", "rimraf": "^5.0.5",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
@ -91,6 +93,7 @@
".di-tokens.ts", ".di-tokens.ts",
".response.ts", ".response.ts",
".port.ts", ".port.ts",
".config.ts",
"prisma.service.ts", "prisma.service.ts",
"main.ts" "main.ts"
], ],
@ -109,6 +112,7 @@
".di-tokens.ts", ".di-tokens.ts",
".response.ts", ".response.ts",
".port.ts", ".port.ts",
".config.ts",
"prisma.service.ts", "prisma.service.ts",
"main.ts" "main.ts"
], ],

View File

@ -1,14 +0,0 @@
-- CreateTable
CREATE TABLE "configuration" (
"uuid" UUID NOT NULL,
"domain" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "configuration_pkey" PRIMARY KEY ("uuid")
);
-- CreateIndex
CREATE UNIQUE INDEX "configuration_domain_key_key" ON "configuration"("domain", "key");

View File

@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@ -1,24 +0,0 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
binaryTargets = ["linux-musl", "debian-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Configuration {
uuid String @id @default(uuid()) @db.Uuid
domain String
key String
value String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([domain, key])
@@map("configuration")
}

View File

@ -5,7 +5,7 @@ import {
} from '@mobicoop/health-module'; } from '@mobicoop/health-module';
import { MessagerModule } from '@modules/messager/messager.module'; import { MessagerModule } from '@modules/messager/messager.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { import {
HEALTH_CONFIGURATION_REPOSITORY, HEALTH_CONFIGURATION_REPOSITORY,
HEALTH_CRITICAL_LOGGING_KEY, HEALTH_CRITICAL_LOGGING_KEY,
@ -18,9 +18,10 @@ import { ConfigurationModule } from '@modules/configuration/configuration.module
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import brokerConfig from './config/broker.config'; import brokerConfig from './config/broker.config';
import carpoolConfig from './config/carpool.config'; import carpoolConfig from './config/carpool.config';
import databaseConfig from './config/database.config';
import paginationConfig from './config/pagination.config'; import paginationConfig from './config/pagination.config';
import serviceConfig from './config/service.config'; import serviceConfig from './config/service.config';
import { RedisModule, RedisModuleOptions } from '@songkeys/nestjs-redis';
import redisConfig from './config/redis.config';
@Module({ @Module({
imports: [ imports: [
@ -29,8 +30,8 @@ import serviceConfig from './config/service.config';
load: [ load: [
brokerConfig, brokerConfig,
carpoolConfig, carpoolConfig,
databaseConfig,
paginationConfig, paginationConfig,
redisConfig,
serviceConfig, serviceConfig,
], ],
}), }),
@ -53,6 +54,21 @@ import serviceConfig from './config/service.config';
messagePublisher, messagePublisher,
}), }),
}), }),
RedisModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (
configService: ConfigService,
): Promise<RedisModuleOptions> => {
return {
config: {
host: configService.get<string>('redis.host') as string,
port: configService.get<number>('redis.port') as number,
password: configService.get<string>('redis.password'),
},
};
},
}),
ConfigurationModule, ConfigurationModule,
MessagerModule, MessagerModule,
], ],

View File

@ -1,7 +0,0 @@
import { registerAs } from '@nestjs/config';
export default registerAs('database', () => ({
url:
process.env.DATABASE_URL ??
'postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=configuration',
}));

View File

@ -0,0 +1,7 @@
import { registerAs } from '@nestjs/config';
export default registerAs('redis', () => ({
host: process.env.REDIS_HOST ?? 'v3-redis',
port: process.env.PORT ? parseInt(process.env.PORT, 10) : 6379,
password: process.env.REDIS_PASSWORD ?? 'redis',
}));

View File

@ -1,22 +1,13 @@
import { Mapper } from '@mobicoop/ddd-library'; import { Mapper } from '@mobicoop/ddd-library';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigurationEntity } from './core/domain/configuration.entity'; import { ConfigurationEntity } from './core/domain/configuration.entity';
import { ConfigurationResponseDto } from './interface/dtos/configuration.response.dto';
import { ConfigurationDomain } from './core/domain/configuration.types';
import { import {
ConfigurationReadModel, ConfigurationReadModel,
ConfigurationWriteModel, ConfigurationWriteModel,
} from './infrastructure/configuration.repository'; } from './infrastructure/configuration.repository';
import { ConfigurationResponseDto } from './interface/dtos/configuration.response.dto'; import { v4 } from 'uuid';
import {
ConfigurationDomain,
ConfigurationType,
} from './core/domain/configuration.types';
/**
* 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() @Injectable()
export class ConfigurationMapper export class ConfigurationMapper
@ -31,27 +22,23 @@ export class ConfigurationMapper
toPersistence = (entity: ConfigurationEntity): ConfigurationWriteModel => { toPersistence = (entity: ConfigurationEntity): ConfigurationWriteModel => {
const copy = entity.getProps(); const copy = entity.getProps();
const record: ConfigurationWriteModel = { const record: ConfigurationWriteModel = {
uuid: entity.id, key: `${copy.identifier.domain}:${copy.identifier.key}`,
domain: copy.identifier.domain,
key: copy.identifier.key,
value: copy.value, value: copy.value,
type: copy.type,
}; };
return record; return record;
}; };
toDomain = (record: ConfigurationReadModel): ConfigurationEntity => { toDomain = (record: ConfigurationReadModel): ConfigurationEntity => {
const entity = new ConfigurationEntity({ const entity = new ConfigurationEntity({
id: record.uuid, id: v4(),
createdAt: new Date(record.createdAt), createdAt: new Date(),
updatedAt: new Date(record.updatedAt), updatedAt: new Date(),
props: { props: {
identifier: { identifier: {
domain: record.domain as ConfigurationDomain, domain: record.key.split(':')[0] as ConfigurationDomain,
key: record.key, key: record.key.split(':')[1],
}, },
value: record.value, value: record.value,
type: record.type as ConfigurationType,
}, },
}); });
return entity; return entity;
@ -63,7 +50,6 @@ export class ConfigurationMapper
response.domain = props.identifier.domain; response.domain = props.identifier.domain;
response.key = props.identifier.key; response.key = props.identifier.key;
response.value = props.value; response.value = props.value;
response.type = props.type;
return response; return response;
}; };
} }

View File

@ -2,45 +2,30 @@ import { Module, Provider } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs'; import { CqrsModule } from '@nestjs/cqrs';
import { GetConfigurationGrpcController } from './interface/grpc-controllers/get-configuration.grpc.controller'; import { GetConfigurationGrpcController } from './interface/grpc-controllers/get-configuration.grpc.controller';
import { SetConfigurationGrpcController } from './interface/grpc-controllers/set-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 { 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 { GetConfigurationQueryHandler } from './core/application/queries/get-configuration/get-configuration.query-handler';
import { ConfigurationMapper } from './configuration.mapper'; import { ConfigurationMapper } from './configuration.mapper';
import { import {
CONFIGURATION_MESSAGE_PUBLISHER, CONFIGURATION_MESSAGE_PUBLISHER,
CONFIGURATION_REPOSITORY, CONFIGURATION_REPOSITORY,
} from './configuration.di-tokens'; } from './configuration.di-tokens';
import { ConfigurationRepository } from './infrastructure/configuration.repository';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module'; import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
import { PrismaService } from './infrastructure/prisma.service'; import { PopulateService } from './core/application/services/populate.service';
import { PropagateConfigurationsService } from './core/application/commands/propagate-configurations/propagate-configurations.service'; import { ConfigurationRepository } from './infrastructure/configuration.repository';
const grpcControllers = [ const grpcControllers = [
GetConfigurationGrpcController, GetConfigurationGrpcController,
SetConfigurationGrpcController, SetConfigurationGrpcController,
DeleteConfigurationGrpcController,
PropagateConfigurationsGrpcController,
]; ];
const eventHandlers: Provider[] = [ const commandHandlers: Provider[] = [SetConfigurationService];
PublishMessageWhenConfigurationIsSetDomainEventHandler,
PublishMessageWhenConfigurationIsDeletedDomainEventHandler,
];
const commandHandlers: Provider[] = [
SetConfigurationService,
DeleteConfigurationService,
PropagateConfigurationsService,
];
const queryHandlers: Provider[] = [GetConfigurationQueryHandler]; const queryHandlers: Provider[] = [GetConfigurationQueryHandler];
const mappers: Provider[] = [ConfigurationMapper]; const mappers: Provider[] = [ConfigurationMapper];
const providers: Provider[] = [PopulateService];
const repositories: Provider[] = [ const repositories: Provider[] = [
{ {
provide: CONFIGURATION_REPOSITORY, provide: CONFIGURATION_REPOSITORY,
@ -54,20 +39,18 @@ const messagePublishers: Provider[] = [
useExisting: MessageBrokerPublisher, useExisting: MessageBrokerPublisher,
}, },
]; ];
const orms: Provider[] = [PrismaService];
@Module({ @Module({
imports: [CqrsModule], imports: [CqrsModule],
controllers: [...grpcControllers], controllers: [...grpcControllers],
providers: [ providers: [
...eventHandlers,
...commandHandlers, ...commandHandlers,
...queryHandlers, ...queryHandlers,
...mappers, ...mappers,
...providers,
...repositories, ...repositories,
...messagePublishers, ...messagePublishers,
...orms,
], ],
exports: [PrismaService, ConfigurationMapper, CONFIGURATION_REPOSITORY], exports: [ConfigurationMapper, CONFIGURATION_REPOSITORY],
}) })
export class ConfigurationModule {} export class ConfigurationModule {}

View File

@ -1,12 +0,0 @@
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

@ -1,26 +0,0 @@
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

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

View File

@ -1,36 +0,0 @@
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

@ -1,53 +1,24 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common'; import { Inject } from '@nestjs/common';
import { AggregateID, NotFoundException } from '@mobicoop/ddd-library';
import { SetConfigurationCommand } from './set-configuration.command'; import { SetConfigurationCommand } from './set-configuration.command';
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens'; import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens';
import { ConfigurationDomain } from '@modules/configuration/core/domain/configuration.types';
import { ConfigurationRepositoryPort } from '../../ports/configuration.repository.port'; import { ConfigurationRepositoryPort } from '../../ports/configuration.repository.port';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import {
ConfigurationDomain,
ConfigurationType,
} from '@modules/configuration/core/domain/configuration.types';
@CommandHandler(SetConfigurationCommand) @CommandHandler(SetConfigurationCommand)
export class SetConfigurationService implements ICommandHandler { export class SetConfigurationService implements ICommandHandler {
constructor( constructor(
@Inject(CONFIGURATION_REPOSITORY) @Inject(CONFIGURATION_REPOSITORY)
private readonly repository: ConfigurationRepositoryPort, private readonly configurationRepository: ConfigurationRepositoryPort,
) {} ) {}
async execute(command: SetConfigurationCommand): Promise<AggregateID> { async execute(command: SetConfigurationCommand): Promise<void> {
try { await this.configurationRepository.set(
const existingConfiguration: ConfigurationEntity = {
await this.repository.findOne({ domain: command.domain as ConfigurationDomain,
domain: command.domain, key: command.key,
key: command.key, },
}); command.value,
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({
identifier: {
domain: command.domain as ConfigurationDomain,
key: command.key,
},
value: command.value,
type: ConfigurationType.STRING,
});
await this.repository.insert(newConfiguration);
return newConfiguration.id;
} catch (error: any) {
throw error;
}
}
throw error;
}
} }
} }

View File

@ -1,33 +0,0 @@
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.identifier.domain,
key: event.identifier.key,
metadata: event.metadata,
});
this.messagePublisher.publish(
CONFIGURATION_DELETED_ROUTING_KEY,
JSON.stringify(configurationDeletedIntegrationEvent),
);
}
}

View File

@ -1,31 +0,0 @@
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.identifier.domain,
key: event.identifier.key,
value: event.value,
metadata: event.metadata,
});
this.messagePublisher.publish(
CONFIGURATION_SET_ROUTING_KEY,
JSON.stringify(configurationSetIntegrationEvent),
);
}
}

View File

@ -1,14 +0,0 @@
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

@ -1,14 +0,0 @@
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

@ -1,4 +1,13 @@
import { RepositoryPort } from '@mobicoop/ddd-library';
import { ConfigurationEntity } from '../../domain/configuration.entity'; import { ConfigurationEntity } from '../../domain/configuration.entity';
import {
ConfigurationIdentifier,
ConfigurationValue,
} from '../../domain/configuration.types';
export type ConfigurationRepositoryPort = RepositoryPort<ConfigurationEntity>; export interface ConfigurationRepositoryPort {
get(identifier: ConfigurationIdentifier): Promise<ConfigurationEntity>;
set(
identifier: ConfigurationIdentifier,
value: ConfigurationValue,
): Promise<void>;
}

View File

@ -2,8 +2,9 @@ import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { GetConfigurationQuery } from './get-configuration.query'; import { GetConfigurationQuery } from './get-configuration.query';
import { Inject } from '@nestjs/common'; import { Inject } from '@nestjs/common';
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens'; 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'; import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import { ConfigurationRepositoryPort } from '../../ports/configuration.repository.port';
import { ConfigurationDomain } from '@modules/configuration/core/domain/configuration.types';
@QueryHandler(GetConfigurationQuery) @QueryHandler(GetConfigurationQuery)
export class GetConfigurationQueryHandler implements IQueryHandler { export class GetConfigurationQueryHandler implements IQueryHandler {
@ -12,6 +13,9 @@ export class GetConfigurationQueryHandler implements IQueryHandler {
private readonly configurationRepository: ConfigurationRepositoryPort, private readonly configurationRepository: ConfigurationRepositoryPort,
) {} ) {}
async execute(query: GetConfigurationQuery): Promise<ConfigurationEntity> { async execute(query: GetConfigurationQuery): Promise<ConfigurationEntity> {
return await this.configurationRepository.findOne(query); return await this.configurationRepository.get({
domain: query.domain as ConfigurationDomain,
key: query.key,
});
} }
} }

View File

@ -0,0 +1,62 @@
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens';
import { Inject, Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ConfigurationRepositoryPort } from '../ports/configuration.repository.port';
import {
CarpoolConfig,
ConfigurationDomain,
PaginationConfig,
} from '../../domain/configuration.types';
import { NotFoundException } from '@mobicoop/ddd-library';
@Injectable()
export class PopulateService implements OnApplicationBootstrap {
constructor(
@Inject(CONFIGURATION_REPOSITORY)
private readonly configurationRepository: ConfigurationRepositoryPort,
private readonly configService: ConfigService,
) {}
onApplicationBootstrap() {
this._populate();
}
private _populate = async (): Promise<void> => {
const carpoolConfig: CarpoolConfig = this.configService.get<CarpoolConfig>(
'carpool',
) as CarpoolConfig;
const paginationConfig: PaginationConfig =
this.configService.get<PaginationConfig>(
'pagination',
) as PaginationConfig;
await Promise.all([
this._populateConfig(ConfigurationDomain.CARPOOL, carpoolConfig),
this._populateConfig(ConfigurationDomain.PAGINATION, paginationConfig),
]);
};
private _populateConfig = async <T>(
domain: ConfigurationDomain,
config: T,
): Promise<void> => {
let key: keyof typeof config;
for (key in config) {
try {
await this.configurationRepository.get({
domain,
key,
});
} catch (error: any) {
if (error instanceof NotFoundException) {
this.configurationRepository.set(
{
domain,
key,
},
`${config[key]}`,
);
}
}
}
};
}

View File

@ -1,32 +0,0 @@
import {
ConfigurationDomain,
ConfigurationType,
ConfigurationItems,
} from './configuration.types';
export const configurationItems: ConfigurationItems = {
[ConfigurationDomain.CARPOOL]: {
seatsProposed: {
value: '3',
type: ConfigurationType.NUMBER,
},
seatsRequested: {
value: '1',
type: ConfigurationType.NUMBER,
},
strictFrequency: {
value: 'false',
type: ConfigurationType.BOOLEAN,
},
departureTimeMargin: {
value: '900',
type: ConfigurationType.NUMBER,
},
role: {
value: 'passenger',
type: ConfigurationType.STRING,
enum: ['driver', 'passenger'],
},
},
[ConfigurationDomain.USER]: {},
};

View File

@ -5,8 +5,6 @@ import {
CreateConfigurationProps, CreateConfigurationProps,
UpdateConfigurationProps, UpdateConfigurationProps,
} from './configuration.types'; } 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> { export class ConfigurationEntity extends AggregateRoot<ConfigurationProps> {
protected readonly _id: AggregateID; protected readonly _id: AggregateID;
@ -15,36 +13,11 @@ export class ConfigurationEntity extends AggregateRoot<ConfigurationProps> {
const id = v4(); const id = v4();
const props: ConfigurationProps = { ...create }; const props: ConfigurationProps = { ...create };
const configuration = new ConfigurationEntity({ id, props }); const configuration = new ConfigurationEntity({ id, props });
configuration.addEvent(
new ConfigurationSetDomainEvent({
aggregateId: id,
identifier: props.identifier,
value: props.value,
type: props.type,
}),
);
return configuration; return configuration;
}; };
update(props: UpdateConfigurationProps): void { update(props: UpdateConfigurationProps): void {
this.props.value = props.value; this.props.value = props.value;
this.addEvent(
new ConfigurationSetDomainEvent({
aggregateId: this._id,
identifier: this.props.identifier,
value: props.value,
type: this.props.type,
}),
);
}
delete(): void {
this.addEvent(
new ConfigurationDeletedDomainEvent({
aggregateId: this.id,
identifier: this.props.identifier,
}),
);
} }
validate(): void { validate(): void {

View File

@ -2,14 +2,12 @@
export interface ConfigurationProps { export interface ConfigurationProps {
identifier: ConfigurationIdentifier; identifier: ConfigurationIdentifier;
value: ConfigurationValue; value: ConfigurationValue;
type: ConfigurationType;
} }
// Properties that are needed for a Configuration creation // Properties that are needed for a Configuration creation
export interface CreateConfigurationProps { export interface CreateConfigurationProps {
identifier: ConfigurationIdentifier; identifier: ConfigurationIdentifier;
value: ConfigurationValue; value: ConfigurationValue;
type: ConfigurationType;
} }
export interface UpdateConfigurationProps { export interface UpdateConfigurationProps {
@ -18,15 +16,10 @@ export interface UpdateConfigurationProps {
export enum ConfigurationDomain { export enum ConfigurationDomain {
CARPOOL = 'CARPOOL', CARPOOL = 'CARPOOL',
PAGINATION = 'PAGINATION',
USER = 'USER', USER = 'USER',
} }
export enum ConfigurationType {
BOOLEAN = 'BOOLEAN',
NUMBER = 'NUMBER',
STRING = 'STRING',
}
export type ConfigurationIdentifier = { export type ConfigurationIdentifier = {
domain: ConfigurationDomain; domain: ConfigurationDomain;
key: ConfigurationKey; key: ConfigurationKey;
@ -35,14 +28,14 @@ export type ConfigurationIdentifier = {
export type ConfigurationKey = string; export type ConfigurationKey = string;
export type ConfigurationValue = string; export type ConfigurationValue = string;
export type ConfigurationItems = Record< export interface CarpoolConfig {
ConfigurationDomain, departureTimeMargin: number;
Record< role: string;
ConfigurationKey, seatsProposed: number;
{ seatsRequested: number;
value: ConfigurationValue; strictFrequency: boolean;
type: ConfigurationType; }
enum?: Array<string>;
} export interface PaginationConfig {
> perPage: number;
>; }

View File

@ -1,11 +0,0 @@
import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library';
import { ConfigurationIdentifier } from '../configuration.types';
export class ConfigurationDeletedDomainEvent extends DomainEvent {
readonly identifier: ConfigurationIdentifier;
constructor(props: DomainEventProps<ConfigurationDeletedDomainEvent>) {
super(props);
this.identifier = props.identifier;
}
}

View File

@ -1,19 +0,0 @@
import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library';
import {
ConfigurationIdentifier,
ConfigurationType,
ConfigurationValue,
} from '../configuration.types';
export class ConfigurationSetDomainEvent extends DomainEvent {
readonly identifier: ConfigurationIdentifier;
readonly value: ConfigurationValue;
readonly type: ConfigurationType;
constructor(props: DomainEventProps<ConfigurationSetDomainEvent>) {
super(props);
this.identifier = props.identifier;
this.type = props.type;
this.value = props.value;
}
}

View File

@ -1,61 +1,47 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Injectable } 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 { ConfigurationRepositoryPort } from '../core/application/ports/configuration.repository.port';
import { PrismaService } from './prisma.service'; import { InjectRedis } from '@songkeys/nestjs-redis';
import { CONFIGURATION_MESSAGE_PUBLISHER } from '../configuration.di-tokens'; import { Redis } from 'ioredis';
import {
ConfigurationIdentifier,
ConfigurationValue,
} from '../core/domain/configuration.types';
import { ConfigurationEntity } from '../core/domain/configuration.entity';
import { ConfigurationMapper } from '../configuration.mapper'; import { ConfigurationMapper } from '../configuration.mapper';
import { NotFoundException } from '@mobicoop/ddd-library';
export type ConfigurationBaseModel = { export type ConfigurationReadModel = {
uuid: string;
domain: string;
key: string; key: string;
value: string; value: string;
type: string;
}; };
export type ConfigurationWriteModel = ConfigurationReadModel;
export type ConfigurationReadModel = ConfigurationBaseModel & {
createdAt: Date;
updatedAt: Date;
};
export type ConfigurationWriteModel = ConfigurationBaseModel;
/**
* Repository is used for retrieving/saving domain entities
* */
@Injectable() @Injectable()
export class ConfigurationRepository export class ConfigurationRepository implements ConfigurationRepositoryPort {
extends PrismaRepositoryBase<
ConfigurationEntity,
ConfigurationReadModel,
ConfigurationWriteModel
>
implements ConfigurationRepositoryPort
{
constructor( constructor(
prisma: PrismaService, @InjectRedis() private readonly redis: Redis,
mapper: ConfigurationMapper, private readonly mapper: ConfigurationMapper,
eventEmitter: EventEmitter2, ) {}
@Inject(CONFIGURATION_MESSAGE_PUBLISHER)
protected readonly messagePublisher: MessagePublisherPort, get = async (
) { identifier: ConfigurationIdentifier,
super( ): Promise<ConfigurationEntity> => {
prisma.configuration, const key: string = `${identifier.domain}:${identifier.key}`;
prisma, const value: ConfigurationValue | null = await this.redis.get(key);
mapper, if (!value)
eventEmitter, throw new NotFoundException(
new LoggerBase({ `Configuration item not found for key ${key}`,
logger: new Logger(ConfigurationRepository.name), );
domain: SERVICE_NAME, return this.mapper.toDomain({
messagePublisher, key,
}), value,
); });
} };
set = async (
identifier: ConfigurationIdentifier,
value: ConfigurationValue,
): Promise<void> => {
await this.redis.set(`${identifier.domain}:${identifier.key}`, value);
};
} }

View File

@ -1,9 +0,0 @@
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

@ -4,5 +4,4 @@ export class ConfigurationResponseDto extends ResponseBase {
domain: string; domain: string;
key: string; key: string;
value: string; value: string;
type: string;
} }

View File

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

View File

@ -1,45 +0,0 @@
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

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

View File

@ -1,33 +0,0 @@
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

@ -1,8 +1,6 @@
import { Controller, UsePipes } from '@nestjs/common'; import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs'; import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices'; 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 { RpcExceptionCode } from '@mobicoop/ddd-library';
import { RpcValidationPipe } from '@mobicoop/ddd-library'; import { RpcValidationPipe } from '@mobicoop/ddd-library';
import { GRPC_SERVICE_NAME } from '@src/app.constants'; import { GRPC_SERVICE_NAME } from '@src/app.constants';
@ -22,12 +20,11 @@ export class SetConfigurationGrpcController {
@GrpcMethod(GRPC_SERVICE_NAME, 'Set') @GrpcMethod(GRPC_SERVICE_NAME, 'Set')
async set( async set(
setConfigurationRequestDto: SetConfigurationRequestDto, setConfigurationRequestDto: SetConfigurationRequestDto,
): Promise<IdResponse> { ): Promise<void> {
try { try {
const aggregateID: AggregateID = await this.commandBus.execute( await this.commandBus.execute(
new SetConfigurationCommand(setConfigurationRequestDto), new SetConfigurationCommand(setConfigurationRequestDto),
); );
return new IdResponse(aggregateID);
} catch (error: any) { } catch (error: any) {
throw new RpcException({ throw new RpcException({
code: RpcExceptionCode.UNKNOWN, code: RpcExceptionCode.UNKNOWN,

View File

@ -1,174 +0,0 @@
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,
ConfigurationDomain,
} 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('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++) {
const configurationToCreate = {
uuid: getSeed(i, baseUuid.uuid),
domain: ConfigurationDomain.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 = 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>(
CONFIGURATION_REPOSITORY,
);
});
afterAll(async () => {
await prismaService.$disconnect();
});
beforeEach(async () => {
await prismaService.configuration.deleteMany();
});
describe('findOne', () => {
it('should return a configuration', async () => {
await createConfigurations(1);
const result = await configurationRepository.findOne({
domain: ConfigurationDomain.AD,
key: 'key0',
});
expect(result.getProps().value).toBe('value0');
});
});
describe('findAll', () => {
it('should return all configurations', async () => {
await createConfigurations(10);
const configurations: ConfigurationEntity[] =
await configurationRepository.findAll({});
expect(configurations).toHaveLength(10);
});
});
describe('create', () => {
it('should create a configuration', async () => {
const beforeCount = await prismaService.configuration.count();
const createConfigurationProps: CreateConfigurationProps = {
domain: ConfigurationDomain.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);
});
});
describe('update', () => {
it('should update a configuration', async () => {
await createConfigurations(1);
const configurationToUpdate: ConfigurationEntity =
await configurationRepository.findOne({
domain: ConfigurationDomain.AD,
key: 'key0',
});
configurationToUpdate.update({ value: 'newValue' });
await configurationRepository.update(
configurationToUpdate.id,
configurationToUpdate,
);
const result: ConfigurationEntity = await configurationRepository.findOne(
{
domain: ConfigurationDomain.AD,
key: 'key0',
},
);
expect(result.getProps().value).toBe('newValue');
});
});
describe('delete', () => {
it('should delete a configuration', async () => {
await createConfigurations(10);
const beforeCount = await prismaService.configuration.count();
const configurationToDelete: ConfigurationEntity =
await configurationRepository.findOne({
domain: ConfigurationDomain.AD,
key: 'key4',
});
await configurationRepository.delete(configurationToDelete);
const afterCount = await prismaService.configuration.count();
expect(afterCount - beforeCount).toBe(-1);
});
});
});

View File

@ -1,9 +1,6 @@
import { ConfigurationMapper } from '@modules/configuration/configuration.mapper'; import { ConfigurationMapper } from '@modules/configuration/configuration.mapper';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity'; import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import { import { ConfigurationDomain } from '@modules/configuration/core/domain/configuration.types';
ConfigurationDomain,
ConfigurationType,
} from '@modules/configuration/core/domain/configuration.types';
import { import {
ConfigurationReadModel, ConfigurationReadModel,
ConfigurationWriteModel, ConfigurationWriteModel,
@ -20,19 +17,13 @@ const configurationEntity: ConfigurationEntity = new ConfigurationEntity({
key: 'seatsProposed', key: 'seatsProposed',
}, },
value: '3', value: '3',
type: ConfigurationType.NUMBER,
}, },
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
const configurationReadModel: ConfigurationReadModel = { const configurationReadModel: ConfigurationReadModel = {
uuid: 'c160cf8c-f057-4962-841f-3ad68346df44', key: 'AD:seatsProposed',
domain: 'AD',
key: 'seatsProposed',
value: '4', value: '4',
type: 'NUMBER',
createdAt: now,
updatedAt: now,
}; };
describe('Configuration Mapper', () => { describe('Configuration Mapper', () => {

View File

@ -3,10 +3,7 @@ import {
CreateConfigurationProps, CreateConfigurationProps,
ConfigurationDomain, ConfigurationDomain,
UpdateConfigurationProps, UpdateConfigurationProps,
ConfigurationType,
} from '@modules/configuration/core/domain/configuration.types'; } 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 = { const createConfigurationProps: CreateConfigurationProps = {
identifier: { identifier: {
@ -14,7 +11,6 @@ const createConfigurationProps: CreateConfigurationProps = {
key: 'seatsProposed', key: 'seatsProposed',
}, },
value: '3', value: '3',
type: ConfigurationType.NUMBER,
}; };
const updateConfigurationProps: UpdateConfigurationProps = { const updateConfigurationProps: UpdateConfigurationProps = {
@ -28,10 +24,6 @@ describe('Configuration entity create', () => {
); );
expect(configurationEntity.id.length).toBe(36); expect(configurationEntity.id.length).toBe(36);
expect(configurationEntity.getProps().value).toBe('3'); expect(configurationEntity.getProps().value).toBe('3');
expect(configurationEntity.domainEvents.length).toBe(1);
expect(configurationEntity.domainEvents[0]).toBeInstanceOf(
ConfigurationSetDomainEvent,
);
}); });
}); });
@ -42,24 +34,5 @@ describe('Configuration entity update', () => {
); );
configurationEntity.update(updateConfigurationProps); configurationEntity.update(updateConfigurationProps);
expect(configurationEntity.getProps().value).toBe('2'); 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

@ -1,56 +0,0 @@
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 { ConfigurationDomain } 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: ConfigurationDomain.CARPOOL,
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

@ -1,9 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity'; import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import { import { ConfigurationDomain } from '@modules/configuration/core/domain/configuration.types';
ConfigurationDomain,
ConfigurationType,
} from '@modules/configuration/core/domain/configuration.types';
import { GetConfigurationQueryHandler } from '@modules/configuration/core/application/queries/get-configuration/get-configuration.query-handler'; import { GetConfigurationQueryHandler } from '@modules/configuration/core/application/queries/get-configuration/get-configuration.query-handler';
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens'; import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens';
import { GetConfigurationQuery } from '@modules/configuration/core/application/queries/get-configuration/get-configuration.query'; import { GetConfigurationQuery } from '@modules/configuration/core/application/queries/get-configuration/get-configuration.query';
@ -17,14 +14,13 @@ const configuration: ConfigurationEntity = new ConfigurationEntity({
key: 'seatsProposed', key: 'seatsProposed',
}, },
value: '3', value: '3',
type: ConfigurationType.NUMBER,
}, },
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
const mockConfigurationRepository = { const mockConfigurationRepository = {
findOne: jest.fn().mockImplementation(() => configuration), get: jest.fn().mockImplementation(() => configuration),
}; };
describe('Get Configuration Query Handler', () => { describe('Get Configuration Query Handler', () => {

View File

@ -0,0 +1,84 @@
import { NotFoundException } from '@mobicoop/ddd-library';
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens';
import { PopulateService } from '@modules/configuration/core/application/services/populate.service';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import { ConfigurationDomain } from '@modules/configuration/core/domain/configuration.types';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
const mockConfigurationRepository = {
get: jest
.fn()
.mockImplementationOnce(
() =>
new ConfigurationEntity({
id: '001199d4-7187-4e83-a044-12159cba2e33',
props: {
identifier: {
domain: ConfigurationDomain.CARPOOL,
key: 'someKey',
},
value: 'someValue',
},
createdAt: new Date('2023-10-23'),
updatedAt: new Date('2023-10-23'),
}),
)
.mockImplementationOnce(() => {
throw new NotFoundException('Configuration not found');
}),
set: jest.fn(),
};
const mockConfigService = {
get: jest.fn().mockImplementation((domain: string) => {
switch (domain) {
case 'carpool':
return {
departureTimeMargin: 900,
role: 'passenger',
seatsProposed: 3,
seatsRequested: 1,
strictFrequency: false,
};
case 'pagination':
return {
perPage: 10,
};
}
}),
};
describe('Populate Service', () => {
let populateService: PopulateService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CONFIGURATION_REPOSITORY,
useValue: mockConfigurationRepository,
},
{
provide: ConfigService,
useValue: mockConfigService,
},
PopulateService,
],
}).compile();
populateService = module.get<PopulateService>(PopulateService);
});
it('should be defined', () => {
expect(populateService).toBeDefined();
});
it('should populate database with default values', () => {
jest.spyOn(mockConfigurationRepository, 'get');
jest.spyOn(mockConfigurationRepository, 'set');
populateService.onApplicationBootstrap();
expect(mockConfigurationRepository.get).toHaveBeenCalled();
expect(mockConfigurationRepository.set).toHaveBeenCalled();
});
});

View File

@ -1,111 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import {
ConfigurationDomain,
ConfigurationType,
} 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: {
identifier: {
domain: ConfigurationDomain.CARPOOL,
key: 'seatsProposed',
},
value: '3',
type: ConfigurationType.NUMBER,
},
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: {
identifier: {
domain: ConfigurationDomain.CARPOOL,
key: 'seatsRequested',
},
value: '1',
type: ConfigurationType.NUMBER,
},
createdAt: new Date('2023-10-23T07:00:00Z'),
updatedAt: new Date('2023-10-23T07:00:00Z'),
}),
];
const mockConfigurationMapper = {
toResponse: jest
.fn()
.mockImplementationOnce(() => ({
domain: ConfigurationDomain.CARPOOL,
key: 'seatsProposed',
value: '3',
type: ConfigurationType.NUMBER,
}))
.mockImplementationOnce(() => ({
domain: ConfigurationDomain.CARPOOL,
key: 'seatsRequested',
value: '1',
type: ConfigurationType.NUMBER,
})),
};
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":"CARPOOL","key":"seatsProposed","value":"3","type":"NUMBER"},{"domain":"CARPOOL","key":"seatsRequested","value":"1","type":"NUMBER"}]',
);
});
});
});

View File

@ -1,58 +0,0 @@
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 { ConfigurationDomain } 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',
identifier: {
domain: ConfigurationDomain.CARPOOL,
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":"CARPOOL","key":"seatsProposed"}',
);
});
});

View File

@ -1,63 +0,0 @@
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 {
ConfigurationDomain,
ConfigurationType,
} 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',
identifier: {
domain: ConfigurationDomain.CARPOOL,
key: 'seatsProposed',
},
type: ConfigurationType.NUMBER,
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":"CARPOOL","key":"seatsProposed","value":"3"}',
);
});
});

View File

@ -1,10 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing'; 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 { SetConfigurationRequestDto } from '@modules/configuration/interface/grpc-controllers/dtos/set-configuration.request.dto';
import { import { ConfigurationDomain } from '@modules/configuration/core/domain/configuration.types';
ConfigurationDomain,
ConfigurationType,
} from '@modules/configuration/core/domain/configuration.types';
import { SetConfigurationService } from '@modules/configuration/core/application/commands/set-configuration/set-configuration.service'; import { SetConfigurationService } from '@modules/configuration/core/application/commands/set-configuration/set-configuration.service';
import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens'; import { CONFIGURATION_REPOSITORY } from '@modules/configuration/configuration.di-tokens';
import { SetConfigurationCommand } from '@modules/configuration/core/application/commands/set-configuration/set-configuration.command'; import { SetConfigurationCommand } from '@modules/configuration/core/application/commands/set-configuration/set-configuration.command';
@ -16,38 +12,8 @@ const setConfigurationRequest: SetConfigurationRequestDto = {
value: '3', value: '3',
}; };
const existingConfigurationEntity = new ConfigurationEntity({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
props: {
identifier: {
domain: ConfigurationDomain.CARPOOL,
key: 'seatsProposed',
},
type: ConfigurationType.NUMBER,
value: '2',
},
createdAt: new Date('2023-10-23T07:00:00Z'),
updatedAt: new Date('2023-10-23T07:00:00Z'),
});
const mockConfigurationRepository = { const mockConfigurationRepository = {
findOne: jest set: 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() .fn()
.mockImplementationOnce(() => ({})) .mockImplementationOnce(() => ({}))
.mockImplementationOnce(() => { .mockImplementationOnce(() => {
@ -82,31 +48,13 @@ describe('Set Configuration Service', () => {
const setConfigurationCommand = new SetConfigurationCommand( const setConfigurationCommand = new SetConfigurationCommand(
setConfigurationRequest, setConfigurationRequest,
); );
it('should create a new configuration item', async () => { it('should set an existing configuration item', async () => {
jest.spyOn(mockConfigurationRepository, 'set');
ConfigurationEntity.create = jest.fn().mockReturnValue({ ConfigurationEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da', id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
}); });
const result: AggregateID = await setConfigurationService.execute( await setConfigurationService.execute(setConfigurationCommand);
setConfigurationCommand, expect(mockConfigurationRepository.set).toHaveBeenCalledTimes(1);
);
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 () => { it('should throw an error if something bad happens on configuration item update', async () => {
ConfigurationEntity.create = jest.fn().mockReturnValue({ ConfigurationEntity.create = jest.fn().mockReturnValue({

View File

@ -1,36 +1,93 @@
import { NotFoundException } from '@mobicoop/ddd-library';
import { ConfigurationMapper } from '@modules/configuration/configuration.mapper'; import { ConfigurationMapper } from '@modules/configuration/configuration.mapper';
import { ConfigurationEntity } from '@modules/configuration/core/domain/configuration.entity';
import { ConfigurationDomain } from '@modules/configuration/core/domain/configuration.types';
import { ConfigurationRepository } from '@modules/configuration/infrastructure/configuration.repository'; 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'; import { Test, TestingModule } from '@nestjs/testing';
import { getRedisToken } from '@songkeys/nestjs-redis';
const mockMessagePublisher = { const mockRedis = {
publish: jest.fn().mockImplementation(), get: jest
.fn()
.mockImplementationOnce(() => '1')
.mockImplementation(() => null),
set: jest.fn().mockImplementation(),
}; };
describe('Configuration repository', () => { const mockConfigurationMapper = {
let prismaService: PrismaService; toDomain: jest.fn().mockImplementation(
let configurationMapper: ConfigurationMapper; () =>
let eventEmitter: EventEmitter2; new ConfigurationEntity({
id: ' 001199d4-7187-4e83-a044-12159cba2e33',
props: {
identifier: {
domain: ConfigurationDomain.CARPOOL,
key: 'seatsProposed',
},
value: '1',
},
createdAt: new Date('2023-10-23'),
updatedAt: new Date('2023-10-23'),
}),
),
};
describe('Configuration Repository', () => {
let configurationRepository: ConfigurationRepository;
beforeAll(async () => { beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
imports: [EventEmitterModule.forRoot()], providers: [
providers: [PrismaService, ConfigurationMapper], {
provide: getRedisToken('default'),
useValue: mockRedis,
},
{
provide: ConfigurationMapper,
useValue: mockConfigurationMapper,
},
ConfigurationRepository,
],
}).compile(); }).compile();
prismaService = module.get<PrismaService>(PrismaService); configurationRepository = module.get<ConfigurationRepository>(
configurationMapper = module.get<ConfigurationMapper>(ConfigurationMapper); ConfigurationRepository,
eventEmitter = module.get<EventEmitter2>(EventEmitter2); );
}); });
it('should be defined', () => { it('should be defined', () => {
expect( expect(configurationRepository).toBeDefined();
new ConfigurationRepository( });
prismaService,
configurationMapper, describe('interact', () => {
eventEmitter, it('should get a value', async () => {
mockMessagePublisher, expect(
), (
).toBeDefined(); await configurationRepository.get({
domain: ConfigurationDomain.CARPOOL,
key: 'seatsProposed',
})
).getProps().value,
).toBe('1');
});
it('should throw if configuration is not found', async () => {
await expect(
configurationRepository.get({
domain: ConfigurationDomain.CARPOOL,
key: 'seatsProposed',
}),
).rejects.toBeInstanceOf(NotFoundException);
});
it('should set a value', async () => {
expect(
await configurationRepository.set(
{
domain: ConfigurationDomain.CARPOOL,
key: 'seatsProposed',
},
'3',
),
).toBeUndefined();
});
}); });
}); });

View File

@ -1,108 +0,0 @@
import {
DatabaseErrorException,
NotFoundException,
} from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { ConfigurationDomain } 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: ConfigurationDomain.CARPOOL,
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

@ -1,10 +1,7 @@
import { NotFoundException } from '@mobicoop/ddd-library'; import { NotFoundException } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library'; import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { ConfigurationMapper } from '@modules/configuration/configuration.mapper'; import { ConfigurationMapper } from '@modules/configuration/configuration.mapper';
import { import { ConfigurationDomain } from '@modules/configuration/core/domain/configuration.types';
ConfigurationDomain,
ConfigurationType,
} from '@modules/configuration/core/domain/configuration.types';
import { GetConfigurationGrpcController } from '@modules/configuration/interface/grpc-controllers/get-configuration.grpc.controller'; import { GetConfigurationGrpcController } from '@modules/configuration/interface/grpc-controllers/get-configuration.grpc.controller';
import { QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices'; import { RpcException } from '@nestjs/microservices';
@ -27,7 +24,6 @@ const mockConfigurationMapper = {
domain: ConfigurationDomain.CARPOOL, domain: ConfigurationDomain.CARPOOL,
key: 'seatsProposed', key: 'seatsProposed',
value: '3', value: '3',
type: ConfigurationType.NUMBER,
})), })),
}; };

View File

@ -1,60 +0,0 @@
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

@ -1,4 +1,3 @@
import { IdResponse } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library'; import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { ConfigurationDomain } from '@modules/configuration/core/domain/configuration.types'; import { ConfigurationDomain } from '@modules/configuration/core/domain/configuration.types';
import { SetConfigurationRequestDto } from '@modules/configuration/interface/grpc-controllers/dtos/set-configuration.request.dto'; import { SetConfigurationRequestDto } from '@modules/configuration/interface/grpc-controllers/dtos/set-configuration.request.dto';
@ -51,11 +50,7 @@ describe('Set Configuration Grpc Controller', () => {
it('should set a configuration item', async () => { it('should set a configuration item', async () => {
jest.spyOn(mockCommandBus, 'execute'); jest.spyOn(mockCommandBus, 'execute');
const result: IdResponse = await setConfigurationGrpcController.set( await setConfigurationGrpcController.set(setConfigurationRequest);
setConfigurationRequest,
);
expect(result).toBeInstanceOf(IdResponse);
expect(result.id).toBe('200d61a8-d878-4378-a609-c19ea71633d2');
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
}); });