Merge branch 'install' into 'main'

Install

See merge request v3/service/ad!1
This commit is contained in:
Sylvain Briat 2023-05-04 10:16:24 +00:00
commit 0f362df74f
64 changed files with 3316 additions and 29 deletions

17
.env.dist Normal file
View File

@ -0,0 +1,17 @@
# SERVICE
SERVICE_URL=0.0.0.0
SERVICE_PORT=5006
SERVICE_CONFIGURATION_DOMAIN=AD
HEALTH_SERVICE_PORT=6006
# PRISMA
DATABASE_URL="postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=ad"
# RABBIT MQ
RMQ_URI=amqp://v3-broker:5672
RMQ_EXCHANGE=mobicoop
# REDIS
REDIS_HOST=v3-redis
REDIS_PASSWORD=redis
REDIS_PORT=6379

54
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,54 @@
image: docker:20.10.22
stages:
- test
- build
##############
# TEST STAGE #
##############
test:
stage: test
image: docker/compose:latest
variables:
DOCKER_TLS_CERTDIR: ''
services:
- docker:dind
script:
- docker-compose -f docker-compose.ci.tools.yml -p ad-tools --env-file ci/.env.ci up -d
- sh ci/wait-up.sh
- docker-compose -f docker-compose.ci.service.yml -p ad-service --env-file ci/.env.ci up -d
# - docker exec -t v3-ad-api sh -c "npm run test:integration:ci"
coverage: /All files[^|]*\|[^|]*\s+([\d\.]+)/
rules:
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH || $CI_COMMIT_MESSAGE =~ /--check/ || $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
when: always
###############
# BUILD STAGE #
###############
build:
stage: build
image: docker:20.10.22
variables:
DOCKER_TLS_CERTDIR: ''
services:
- docker:dind
before_script:
- echo -n $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY
script:
- export VERSION=$(docker run --rm -v "$PWD":/usr/src/app:ro -w /usr/src/app node:slim node -p "require('./package.json').version")
- docker pull $CI_REGISTRY_IMAGE:latest || true
- >
docker build
--pull
--cache-from $CI_REGISTRY_IMAGE:latest
--tag $CI_REGISTRY_IMAGE:$VERSION
--tag $CI_REGISTRY_IMAGE:latest
.
- docker push $CI_REGISTRY_IMAGE:$VERSION
- docker push $CI_REGISTRY_IMAGE:latest
only:
- main

77
Dockerfile Normal file
View File

@ -0,0 +1,77 @@
###################
# BUILD FOR LOCAL DEVELOPMENT
###################
FROM node:18-alpine3.16 As development
# Create app directory
WORKDIR /usr/src/app
# Copy application dependency manifests to the container image.
# A wildcard is used to ensure copying both package.json AND package-lock.json (when available).
# Copying this first prevents re-running npm install on every code change.
COPY --chown=node:node package*.json ./
# Copy prisma (needed for prisma error types)
COPY --chown=node:node ./prisma prisma
# Install app dependencies using the `npm ci` command instead of `npm install`
RUN npm ci
RUN npx prisma generate
# Bundle app source
COPY --chown=node:node . .
# Use the node user from the image (instead of the root user)
USER node
###################
# BUILD FOR PRODUCTION
###################
FROM node:18-alpine3.16 As build
WORKDIR /usr/src/app
COPY --chown=node:node package*.json ./
# In order to run `npm run build` we need access to the Nest CLI.
# The Nest CLI is a dev dependency,
# In the previous development stage we ran `npm ci` which installed all dependencies.
# So we can copy over the node_modules directory from the development image into this build image.
COPY --chown=node:node --from=development /usr/src/app/node_modules ./node_modules
COPY --chown=node:node . .
# Copy prisma (needed for migrations)
COPY --chown=node:node ./prisma prisma
# Run the build command which creates the production bundle
RUN npm run build
# Set NODE_ENV environment variable
ENV NODE_ENV production
# Running `npm ci` removes the existing node_modules directory.
# Passing in --omit=dev ensures that only the production dependencies are installed.
# This ensures that the node_modules directory is as optimized as possible.
RUN npm ci --omit=dev && npm cache clean --force
USER node
###################
# PRODUCTION
###################
FROM node:18-alpine3.16 As production
# Copy package.json to be able to execute migration command
COPY --chown=node:node package*.json ./
# Copy the bundled code from the build stage to the production image
COPY --chown=node:node --from=build /usr/src/app/node_modules ./node_modules
COPY --chown=node:node --from=build /usr/src/app/prisma ./prisma
COPY --chown=node:node --from=build /usr/src/app/dist ./dist
# Start the server using the production build
CMD [ "node", "dist/main.js" ]

15
ci/.env.ci Normal file
View File

@ -0,0 +1,15 @@
# SERVICE
SERVICE_URL=0.0.0.0
SERVICE_PORT=5006
# PRISMA
DATABASE_URL="postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=public"
# RABBIT MQ
RMQ_URI=amqp://v3-broker:5672
# MESSAGE BROKER
BROKER_IMAGE=rabbitmq:3-alpine
# POSTGRES
POSTGRES_IMAGE=postgis/postgis:15-3.3

33
ci/Dockerfile Normal file
View File

@ -0,0 +1,33 @@
###################
# BUILD FOR CI TESTING
###################
FROM node:18-alpine3.16
# Create app directory
WORKDIR /usr/src/app
# A wildcard is used to ensure both package.json AND package-lock.json are copied
COPY package*.json ./
# Install app dependencies
RUN npm ci
# Bundle app source
COPY . .
# Generate prisma client
RUN npx prisma generate
# Create a "dist" folder
RUN npm run build
# Run unit tests
RUN npm run test:unit:ci
# ESLint / Prettier
RUN npm run lint:check
RUN npm run pretty:check
# Start the server
CMD [ "node", "dist/main.js" ]

12
ci/wait-up.sh Normal file
View File

@ -0,0 +1,12 @@
#!/bin/bash
testlog() {
docker logs v3-db | grep -q "database system is ready to accept connections"
}
testlog 2> /dev/null
while [ $? -ne 0 ];
do
sleep 5
echo "Waiting for Test DB to be up..."
testlog 2> /dev/null
done

View File

@ -0,0 +1,19 @@
version: '3.8'
services:
v3-ad-api:
container_name: v3-ad-api
build:
dockerfile: ci/Dockerfile
context: .
env_file:
- ci/.env.ci
ports:
- 5006:5006
networks:
- v3-network
networks:
v3-network:
name: v3-network
external: true

View File

@ -0,0 +1,26 @@
version: '3.8'
services:
db:
container_name: v3-db
image: ${POSTGRES_IMAGE}
environment:
POSTGRES_DB: mobicoop
POSTGRES_USER: mobicoop
POSTGRES_PASSWORD: mobicoop
ports:
- 5432:5432
networks:
- v3-network
broker:
container_name: v3-broker
image: ${BROKER_IMAGE}
ports:
- 5672:5672
networks:
- v3-network
networks:
v3-network:
name: v3-network

26
docker-compose.yml Normal file
View File

@ -0,0 +1,26 @@
version: '3.8'
services:
v3-ad-api:
container_name: v3-ad-api
build:
dockerfile: Dockerfile
context: .
target: development
volumes:
- .:/usr/src/app
env_file:
- .env
command: npm run start:dev
ports:
- ${SERVICE_PORT:-5006}:${SERVICE_PORT:-5006}
- ${HEALTH_SERVICE_PORT:-6006}:${HEALTH_SERVICE_PORT:-6006}
networks:
v3-network:
aliases:
- v3-ad-api
networks:
v3-network:
name: v3-network
external: true

841
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@
"private": true,
"license": "AGPL",
"scripts": {
"prebuild": "rimraf dist",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
@ -13,16 +14,41 @@
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix-dry-run --ignore-path .gitignore",
"pretty:check": "./node_modules/.bin/prettier --check .",
"pretty": "./node_modules/.bin/prettier --write .",
"test": "npm run migrate:test && dotenv -e .env.test jest",
"test:unit": "jest --testPathPattern 'tests/unit/' --verbose",
"test:unit:ci": "jest --testPathPattern 'tests/unit/' --coverage",
"test:integration": "npm run migrate:test && dotenv -e .env.test -- jest --testPathPattern 'tests/integration/' --verbose",
"test:integration:ci": "npm run migrate:test:ci && dotenv -e ci/.env.ci -- jest --testPathPattern 'tests/integration/'",
"test:cov": "jest --testPathPattern 'tests/unit/' --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json",
"generate": "docker exec v3-ad-api sh -c 'npx prisma generate'",
"migrate": "docker exec v3-ad-api sh -c 'npx prisma migrate dev'",
"migrate:test": "dotenv -e .env.test -- npx prisma migrate deploy",
"migrate:test:ci": "dotenv -e ci/.env.ci -- npx prisma migrate deploy",
"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.6.0",
"@grpc/grpc-js": "^1.8.14",
"@grpc/proto-loader": "^0.7.6",
"@liaoliaots/nestjs-redis": "^9.0.5",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.3.1",
"@nestjs/core": "^9.0.0",
"@nestjs/cqrs": "^9.0.3",
"@nestjs/microservices": "^9.4.0",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/terminus": "^9.2.2",
"@prisma/client": "^4.13.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"ioredis": "^5.3.2",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.2.0"
},
@ -41,6 +67,7 @@
"eslint-plugin-prettier": "^4.0.0",
"jest": "29.5.0",
"prettier": "^2.3.2",
"prisma": "^4.13.0",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",
"ts-jest": "29.0.5",
@ -55,6 +82,15 @@
"json",
"ts"
],
"modulePathIgnorePatterns": [
".controller.ts",
".module.ts",
".request.ts",
".presenter.ts",
".profile.ts",
".exception.ts",
"main.ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
@ -63,6 +99,15 @@
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coveragePathIgnorePatterns": [
".controller.ts",
".module.ts",
".request.ts",
".presenter.ts",
".profile.ts",
".exception.ts",
"main.ts"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}

View File

@ -0,0 +1,70 @@
-- CreateEnum
CREATE TYPE "Frequency" AS ENUM ('PUNCTUAL', 'RECURRENT');
-- CreateEnum
CREATE TYPE "AddressType" AS ENUM ('HOUSE_NUMBER', 'STREET_ADDRESS', 'LOCALITY', 'VENUE', 'OTHER');
-- CreateTable
CREATE TABLE "ad" (
"uuid" UUID NOT NULL,
"userUuid" UUID NOT NULL,
"driver" BOOLEAN,
"passenger" BOOLEAN,
"frequency" "Frequency" NOT NULL DEFAULT 'RECURRENT',
"fromDate" DATE NOT NULL,
"toDate" DATE,
"monTime" TIMESTAMPTZ,
"tueTime" TIMESTAMPTZ,
"wedTime" TIMESTAMPTZ,
"thuTime" TIMESTAMPTZ,
"friTime" TIMESTAMPTZ,
"satTime" TIMESTAMPTZ,
"sunTime" TIMESTAMPTZ,
"monMargin" INTEGER,
"tueMargin" INTEGER,
"wedMargin" INTEGER,
"thuMargin" INTEGER,
"friMargin" INTEGER,
"satMargin" INTEGER,
"sunMargin" INTEGER,
"seatsDriver" SMALLINT,
"seatsPassenger" SMALLINT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ad_pkey" PRIMARY KEY ("uuid")
);
-- CreateTable
CREATE TABLE "address" (
"uuid" UUID NOT NULL,
"adUuid" UUID NOT NULL,
"position" SMALLINT NOT NULL,
"lon" DOUBLE PRECISION NOT NULL,
"lat" DOUBLE PRECISION NOT NULL,
"houseNumber" TEXT,
"street" TEXT,
"locality" TEXT,
"postalCode" TEXT,
"country" TEXT,
"type" "AddressType" DEFAULT 'OTHER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "address_pkey" PRIMARY KEY ("uuid")
);
-- CreateIndex
CREATE INDEX "ad_driver_idx" ON "ad"("driver");
-- CreateIndex
CREATE INDEX "ad_passenger_idx" ON "ad"("passenger");
-- CreateIndex
CREATE INDEX "ad_fromDate_idx" ON "ad"("fromDate");
-- CreateIndex
CREATE INDEX "ad_toDate_idx" ON "ad"("toDate");
-- AddForeignKey
ALTER TABLE "address" ADD CONSTRAINT "address_adUuid_fkey" FOREIGN KEY ("adUuid") REFERENCES "ad"("uuid") ON DELETE CASCADE ON UPDATE CASCADE;

View File

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

78
prisma/schema.prisma Normal file
View File

@ -0,0 +1,78 @@
// 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"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Ad {
uuid String @id @default(uuid()) @db.Uuid
userUuid String @db.Uuid
driver Boolean?
passenger Boolean?
frequency Frequency @default(RECURRENT)
fromDate DateTime @db.Date
toDate DateTime? @db.Date
monTime DateTime? @db.Timestamptz()
tueTime DateTime? @db.Timestamptz()
wedTime DateTime? @db.Timestamptz()
thuTime DateTime? @db.Timestamptz()
friTime DateTime? @db.Timestamptz()
satTime DateTime? @db.Timestamptz()
sunTime DateTime? @db.Timestamptz()
monMargin Int?
tueMargin Int?
wedMargin Int?
thuMargin Int?
friMargin Int?
satMargin Int?
sunMargin Int?
seatsDriver Int? @db.SmallInt
seatsPassenger Int? @db.SmallInt
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
addresses Address[]
@@index([driver])
@@index([passenger])
@@index([fromDate])
@@index([toDate])
@@map("ad")
}
model Address {
uuid String @id @default(uuid()) @db.Uuid
adUuid String @db.Uuid
position Int @db.SmallInt
lon Float
lat Float
houseNumber String?
street String?
locality String?
postalCode String?
country String?
type AddressType? @default(OTHER)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
Ad Ad @relation(fields: [adUuid], references: [uuid], onDelete: Cascade)
@@map("address")
}
enum Frequency {
PUNCTUAL
RECURRENT
}
enum AddressType {
HOUSE_NUMBER
STREET_ADDRESS
LOCALITY
VENUE
OTHER
}

View File

@ -1,7 +1,19 @@
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 { AdModule } from './modules/ad/ad.module';
import { AutomapperModule } from '@automapper/nestjs';
import { classes } from '@automapper/classes';
@Module({
imports: [],
imports: [
ConfigModule.forRoot({ isGlobal: true }),
AutomapperModule.forRoot({ strategyInitializer: classes() }),
ConfigurationModule,
HealthModule,
AdModule,
],
controllers: [],
providers: [],
})

View File

@ -1,8 +1,27 @@
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { join } from 'path';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.TCP,
});
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.GRPC,
options: {
package: ['ad', 'health'],
protoPath: [
join(__dirname, 'modules/ad/adapters/primaries/ad.proto'),
join(__dirname, 'modules/health/adapters/primaries/health.proto'),
],
url: process.env.SERVICE_URL + ':' + process.env.SERVICE_PORT,
loader: { keepCase: true },
},
});
await app.startAllMicroservices();
await app.listen(process.env.HEALTH_SERVICE_PORT);
}
bootstrap();

View File

@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { AdController } from './adapters/primaries/ad.controller';
import { DatabaseModule } from '../database/database.module';
import { CqrsModule } from '@nestjs/cqrs';
import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AdProfile } from './mappers/ad.profile';
import { AdsRepository } from './adapters/secondaries/ads.repository';
import { Messager } from './adapters/secondaries/messager';
import { FindAdByUuidUseCase } from './domain/usecases/find-ad-by-uuid.usecase';
@Module({
imports: [
DatabaseModule,
CqrsModule,
RabbitMQModule.forRootAsync(RabbitMQModule, {
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
exchanges: [
{
name: configService.get<string>('RMQ_EXCHANGE'),
type: 'topic',
},
],
uri: configService.get<string>('RMQ_URI'),
connectionInitOptions: { wait: false },
}),
inject: [ConfigService],
}),
],
controllers: [AdController],
providers: [AdProfile, AdsRepository, Messager, FindAdByUuidUseCase],
})
export class AdModule {}

View File

@ -0,0 +1,38 @@
import { Mapper } from '@automapper/core';
import { InjectMapper } from '@automapper/nestjs';
import { Controller, UsePipes } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { RpcValidationPipe } from '../../../../utils/pipes/rpc.validation-pipe';
import { FindAdByUuidRequest } from '../../domain/dtos/find-ad-by-uuid.request';
import { AdPresenter } from './ad.presenter';
import { FindAdByUuidQuery } from '../../queries/find-ad-by-uuid.query';
import { Ad } from '../../domain/entities/ad';
@UsePipes(
new RpcValidationPipe({
whitelist: false,
forbidUnknownValues: false,
}),
)
@Controller()
export class AdController {
constructor(
private readonly queryBus: QueryBus,
@InjectMapper() private readonly _mapper: Mapper,
) {}
@GrpcMethod('AdsService', 'FindOneByUuid')
async findOnebyUuid(data: FindAdByUuidRequest): Promise<AdPresenter> {
try {
console.log('ici');
const ad = await this.queryBus.execute(new FindAdByUuidQuery(data));
return this._mapper.map(ad, Ad, AdPresenter);
} catch (e) {
throw new RpcException({
code: e.code,
message: e.message,
});
}
}
}

View File

@ -0,0 +1,9 @@
import { AutoMap } from '@automapper/classes';
export class AdPresenter {
@AutoMap()
uuid: string;
@AutoMap()
driver: boolean;
}

View File

@ -0,0 +1,86 @@
syntax = "proto3";
package ad;
service AdsService {
rpc FindOneByUuid(AdByUuid) returns (Ad);
rpc FindAll(AdFilter) returns (Ads);
rpc Create(Ad) returns (Ad);
rpc Update(Ad) returns (Ad);
rpc Delete(AdByUuid) returns (Empty);
}
message AdByUuid {
string uuid = 1;
}
message Ad {
string uuid = 1;
string userUuid = 2;
bool driver = 3;
bool passenger = 4;
int32 frequency = 5;
string fromDate = 6;
string toDate = 7;
Schedule schedule = 8;
MarginDurations marginDurations = 9;
int32 seatsPassenger = 10;
int32 seatsDriver = 11;
bool strict = 12;
Addresses addresses = 13;
}
message Schedule {
string mon = 1;
string tue = 2;
string wed = 3;
string thu = 4;
string fri = 5;
string sat = 6;
string sun = 7;
}
message MarginDurations {
int32 mon = 1;
int32 tue = 2;
int32 wed = 3;
int32 thu = 4;
int32 fri = 5;
int32 sat = 6;
int32 sun = 7;
}
message Addresses {
repeated Address address = 1;
}
message Address {
float lon = 1;
float lat = 2;
string houseNumber = 3;
string street = 4;
string locality = 5;
string postalCode = 6;
string country = 7;
AddressType type = 8;
}
enum AddressType {
HOUSE_NUMBER = 1;
STREET_ADDRESS = 2;
LOCALITY = 3;
VENUE = 4;
OTHER = 5;
}
message AdFilter {
optional int32 page = 1;
optional int32 perPage = 2;
}
message Ads {
repeated Ad data = 1;
int32 total = 2;
}
message Empty {}

View File

@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
import { AdRepository } from '../../../database/domain/ad-repository';
import { Ad } from '../../domain/entities/ad';
@Injectable()
export class AdsRepository extends AdRepository<Ad> {
protected _model = 'ad';
}

View File

@ -0,0 +1,18 @@
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,
configService: ConfigService,
) {
super(configService.get<string>('RMQ_EXCHANGE'));
}
publish(routingKey: string, message: string): void {
this._amqpConnection.publish(this.exchange, routingKey, message);
}
}

View File

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

View File

@ -0,0 +1,9 @@
import { AutoMap } from '@automapper/classes';
export class Ad {
@AutoMap()
uuid: string;
@AutoMap()
driver: boolean;
}

View File

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

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

View File

@ -0,0 +1,18 @@
import { createMap, Mapper } from '@automapper/core';
import { AutomapperProfile, InjectMapper } from '@automapper/nestjs';
import { Injectable } from '@nestjs/common';
import { Ad } from '../domain/entities/ad';
import { AdPresenter } from '../adapters/primaries/ad.presenter';
@Injectable()
export class AdProfile extends AutomapperProfile {
constructor(@InjectMapper() mapper: Mapper) {
super(mapper);
}
override get profile() {
return (mapper) => {
createMap(mapper, Ad, AdPresenter);
};
}
}

View File

@ -0,0 +1,9 @@
import { FindAdByUuidRequest } from '../domain/dtos/find-ad-by-uuid.request';
export class FindAdByUuidQuery {
readonly uuid: string;
constructor(findAdByUuidRequest: FindAdByUuidRequest) {
this.uuid = findAdByUuidRequest.uuid;
}
}

View File

@ -0,0 +1,77 @@
import { RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
import { Controller } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CommandBus } from '@nestjs/cqrs';
import { DeleteConfigurationCommand } from '../../commands/delete-configuration.command';
import { SetConfigurationCommand } from '../../commands/set-configuration.command';
import { DeleteConfigurationRequest } from '../../domain/dtos/delete-configuration.request';
import { SetConfigurationRequest } from '../../domain/dtos/set-configuration.request';
import { Configuration } from '../../domain/entities/configuration';
@Controller()
export class ConfigurationMessagerController {
constructor(
private readonly _commandBus: CommandBus,
private readonly _configService: ConfigService,
) {}
@RabbitSubscribe({
name: 'setConfiguration',
})
public async setConfigurationHandler(message: string) {
const configuration: Configuration = JSON.parse(message);
if (
configuration.domain ==
this._configService.get<string>('SERVICE_CONFIGURATION_DOMAIN')
) {
const setConfigurationRequest: SetConfigurationRequest =
new SetConfigurationRequest();
setConfigurationRequest.domain = configuration.domain;
setConfigurationRequest.key = configuration.key;
setConfigurationRequest.value = configuration.value;
await this._commandBus.execute(
new SetConfigurationCommand(setConfigurationRequest),
);
}
}
@RabbitSubscribe({
name: 'deleteConfiguration',
})
public async configurationDeletedHandler(message: string) {
const deletedConfiguration: Configuration = JSON.parse(message);
if (
deletedConfiguration.domain ==
this._configService.get<string>('SERVICE_CONFIGURATION_DOMAIN')
) {
const deleteConfigurationRequest = new DeleteConfigurationRequest();
deleteConfigurationRequest.domain = deletedConfiguration.domain;
deleteConfigurationRequest.key = deletedConfiguration.key;
await this._commandBus.execute(
new DeleteConfigurationCommand(deleteConfigurationRequest),
);
}
}
@RabbitSubscribe({
name: 'propagateConfiguration',
})
public async propagateConfigurationsHandler(message: string) {
const configurations: Array<Configuration> = JSON.parse(message);
configurations.forEach(async (configuration) => {
if (
configuration.domain ==
this._configService.get<string>('SERVICE_CONFIGURATION_DOMAIN')
) {
const setConfigurationRequest: SetConfigurationRequest =
new SetConfigurationRequest();
setConfigurationRequest.domain = configuration.domain;
setConfigurationRequest.key = configuration.key;
setConfigurationRequest.value = configuration.value;
await this._commandBus.execute(
new SetConfigurationCommand(setConfigurationRequest),
);
}
});
}
}

View File

@ -0,0 +1,23 @@
import { InjectRedis } from '@liaoliaots/nestjs-redis';
import { Injectable } from '@nestjs/common';
import { Redis } from 'ioredis';
import { IConfigurationRepository } from '../../domain/interfaces/configuration.repository';
@Injectable()
export class RedisConfigurationRepository extends IConfigurationRepository {
constructor(@InjectRedis() private readonly _redis: Redis) {
super();
}
async get(key: string): Promise<string> {
return await this._redis.get(key);
}
async set(key: string, value: string) {
await this._redis.set(key, value);
}
async del(key: string) {
await this._redis.del(key);
}
}

View File

@ -0,0 +1,9 @@
import { DeleteConfigurationRequest } from '../domain/dtos/delete-configuration.request';
export class DeleteConfigurationCommand {
readonly deleteConfigurationRequest: DeleteConfigurationRequest;
constructor(deleteConfigurationRequest: DeleteConfigurationRequest) {
this.deleteConfigurationRequest = deleteConfigurationRequest;
}
}

View File

@ -0,0 +1,9 @@
import { SetConfigurationRequest } from '../domain/dtos/set-configuration.request';
export class SetConfigurationCommand {
readonly setConfigurationRequest: SetConfigurationRequest;
constructor(setConfigurationRequest: SetConfigurationRequest) {
this.setConfigurationRequest = setConfigurationRequest;
}
}

View File

@ -0,0 +1,68 @@
import { RabbitMQConfig, RabbitMQModule } from '@golevelup/nestjs-rabbitmq';
import { RedisModule, RedisModuleOptions } from '@liaoliaots/nestjs-redis';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CqrsModule } from '@nestjs/cqrs';
import { ConfigurationMessagerController } from './adapters/primaries/configuration-messager.controller';
import { RedisConfigurationRepository } from './adapters/secondaries/redis-configuration.repository';
import { DeleteConfigurationUseCase } from './domain/usecases/delete-configuration.usecase';
import { GetConfigurationUseCase } from './domain/usecases/get-configuration.usecase';
import { SetConfigurationUseCase } from './domain/usecases/set-configuration.usecase';
@Module({
imports: [
CqrsModule,
RedisModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (
configService: ConfigService,
): Promise<RedisModuleOptions> => ({
config: {
host: configService.get<string>('REDIS_HOST'),
port: configService.get<number>('REDIS_PORT'),
password: configService.get<string>('REDIS_PASSWORD'),
},
}),
}),
RabbitMQModule.forRootAsync(RabbitMQModule, {
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (
configService: ConfigService,
): Promise<RabbitMQConfig> => ({
exchanges: [
{
name: configService.get<string>('RMQ_EXCHANGE'),
type: 'topic',
},
],
handlers: {
setConfiguration: {
exchange: configService.get<string>('RMQ_EXCHANGE'),
routingKey: ['configuration.create', 'configuration.update'],
},
deleteConfiguration: {
exchange: configService.get<string>('RMQ_EXCHANGE'),
routingKey: 'configuration.delete',
},
propagateConfiguration: {
exchange: configService.get<string>('RMQ_EXCHANGE'),
routingKey: 'configuration.propagate',
},
},
uri: configService.get<string>('RMQ_URI'),
connectionInitOptions: { wait: false },
enableControllerDiscovery: true,
}),
}),
],
controllers: [ConfigurationMessagerController],
providers: [
GetConfigurationUseCase,
SetConfigurationUseCase,
DeleteConfigurationUseCase,
RedisConfigurationRepository,
],
})
export class ConfigurationModule {}

View File

@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class DeleteConfigurationRequest {
@IsString()
@IsNotEmpty()
domain: string;
@IsString()
@IsNotEmpty()
key: string;
}

View File

@ -0,0 +1,15 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class SetConfigurationRequest {
@IsString()
@IsNotEmpty()
domain: string;
@IsString()
@IsNotEmpty()
key: string;
@IsString()
@IsNotEmpty()
value: string;
}

View File

@ -0,0 +1,12 @@
import { AutoMap } from '@automapper/classes';
export class Configuration {
@AutoMap()
domain: string;
@AutoMap()
key: string;
@AutoMap()
value: string;
}

View File

@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export abstract class IConfigurationRepository {
abstract get(key: string): Promise<string>;
abstract set(key: string, value: string): void;
abstract del(key: string): void;
}

View File

@ -0,0 +1,16 @@
import { CommandHandler } from '@nestjs/cqrs';
import { RedisConfigurationRepository } from '../../adapters/secondaries/redis-configuration.repository';
import { DeleteConfigurationCommand } from '../../commands/delete-configuration.command';
@CommandHandler(DeleteConfigurationCommand)
export class DeleteConfigurationUseCase {
constructor(private _configurationRepository: RedisConfigurationRepository) {}
async execute(deleteConfigurationCommand: DeleteConfigurationCommand) {
await this._configurationRepository.del(
deleteConfigurationCommand.deleteConfigurationRequest.domain +
':' +
deleteConfigurationCommand.deleteConfigurationRequest.key,
);
}
}

View File

@ -0,0 +1,14 @@
import { QueryHandler } from '@nestjs/cqrs';
import { RedisConfigurationRepository } from '../../adapters/secondaries/redis-configuration.repository';
import { GetConfigurationQuery } from '../../queries/get-configuration.query';
@QueryHandler(GetConfigurationQuery)
export class GetConfigurationUseCase {
constructor(private _configurationRepository: RedisConfigurationRepository) {}
async execute(getConfigurationQuery: GetConfigurationQuery): Promise<string> {
return this._configurationRepository.get(
getConfigurationQuery.domain + ':' + getConfigurationQuery.key,
);
}
}

View File

@ -0,0 +1,17 @@
import { CommandHandler } from '@nestjs/cqrs';
import { RedisConfigurationRepository } from '../../adapters/secondaries/redis-configuration.repository';
import { SetConfigurationCommand } from '../../commands/set-configuration.command';
@CommandHandler(SetConfigurationCommand)
export class SetConfigurationUseCase {
constructor(private _configurationRepository: RedisConfigurationRepository) {}
async execute(setConfigurationCommand: SetConfigurationCommand) {
await this._configurationRepository.set(
setConfigurationCommand.setConfigurationRequest.domain +
':' +
setConfigurationCommand.setConfigurationRequest.key,
setConfigurationCommand.setConfigurationRequest.value,
);
}
}

View File

@ -0,0 +1,9 @@
export class GetConfigurationQuery {
readonly domain: string;
readonly key: string;
constructor(domain: string, key: string) {
this.domain = domain;
this.key = key;
}
}

View File

@ -0,0 +1,49 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RedisConfigurationRepository } from '../../adapters/secondaries/redis-configuration.repository';
import { DeleteConfigurationCommand } from '../../commands/delete-configuration.command';
import { DeleteConfigurationRequest } from '../../domain/dtos/delete-configuration.request';
import { DeleteConfigurationUseCase } from '../../domain/usecases/delete-configuration.usecase';
const mockRedisConfigurationRepository = {
del: jest.fn().mockResolvedValue(undefined),
};
describe('DeleteConfigurationUseCase', () => {
let deleteConfigurationUseCase: DeleteConfigurationUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: RedisConfigurationRepository,
useValue: mockRedisConfigurationRepository,
},
DeleteConfigurationUseCase,
],
}).compile();
deleteConfigurationUseCase = module.get<DeleteConfigurationUseCase>(
DeleteConfigurationUseCase,
);
});
it('should be defined', () => {
expect(deleteConfigurationUseCase).toBeDefined();
});
describe('execute', () => {
it('should delete a key', async () => {
jest.spyOn(mockRedisConfigurationRepository, 'del');
const deleteConfigurationRequest: DeleteConfigurationRequest = {
domain: 'my-domain',
key: 'my-key',
};
await deleteConfigurationUseCase.execute(
new DeleteConfigurationCommand(deleteConfigurationRequest),
);
expect(mockRedisConfigurationRepository.del).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -0,0 +1,43 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RedisConfigurationRepository } from '../../adapters/secondaries/redis-configuration.repository';
import { GetConfigurationUseCase } from '../../domain/usecases/get-configuration.usecase';
import { GetConfigurationQuery } from '../../queries/get-configuration.query';
const mockRedisConfigurationRepository = {
get: jest.fn().mockResolvedValue('my-value'),
};
describe('GetConfigurationUseCase', () => {
let getConfigurationUseCase: GetConfigurationUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: RedisConfigurationRepository,
useValue: mockRedisConfigurationRepository,
},
GetConfigurationUseCase,
],
}).compile();
getConfigurationUseCase = module.get<GetConfigurationUseCase>(
GetConfigurationUseCase,
);
});
it('should be defined', () => {
expect(getConfigurationUseCase).toBeDefined();
});
describe('execute', () => {
it('should get a value for a key', async () => {
const value: string = await getConfigurationUseCase.execute(
new GetConfigurationQuery('my-domain', 'my-key'),
);
expect(value).toBe('my-value');
});
});
});

View File

@ -0,0 +1,47 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RedisConfigurationRepository } from '../../adapters/secondaries/redis-configuration.repository';
import { getRedisToken } from '@liaoliaots/nestjs-redis';
const mockRedis = {
get: jest.fn().mockResolvedValue('myValue'),
set: jest.fn().mockImplementation(),
del: jest.fn().mockImplementation(),
};
describe('RedisConfigurationRepository', () => {
let redisConfigurationRepository: RedisConfigurationRepository;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: getRedisToken('default'),
useValue: mockRedis,
},
RedisConfigurationRepository,
],
}).compile();
redisConfigurationRepository = module.get<RedisConfigurationRepository>(
RedisConfigurationRepository,
);
});
it('should be defined', () => {
expect(redisConfigurationRepository).toBeDefined();
});
describe('interact', () => {
it('should get a value', async () => {
expect(await redisConfigurationRepository.get('myKey')).toBe('myValue');
});
it('should set a value', async () => {
expect(
await redisConfigurationRepository.set('myKey', 'myValue'),
).toBeUndefined();
});
it('should delete a value', async () => {
expect(await redisConfigurationRepository.del('myKey')).toBeUndefined();
});
});
});

View File

@ -0,0 +1,50 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RedisConfigurationRepository } from '../../adapters/secondaries/redis-configuration.repository';
import { SetConfigurationCommand } from '../../commands/set-configuration.command';
import { SetConfigurationRequest } from '../../domain/dtos/set-configuration.request';
import { SetConfigurationUseCase } from '../../domain/usecases/set-configuration.usecase';
const mockRedisConfigurationRepository = {
set: jest.fn().mockResolvedValue(undefined),
};
describe('SetConfigurationUseCase', () => {
let setConfigurationUseCase: SetConfigurationUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: RedisConfigurationRepository,
useValue: mockRedisConfigurationRepository,
},
SetConfigurationUseCase,
],
}).compile();
setConfigurationUseCase = module.get<SetConfigurationUseCase>(
SetConfigurationUseCase,
);
});
it('should be defined', () => {
expect(setConfigurationUseCase).toBeDefined();
});
describe('execute', () => {
it('should set a value for a key', async () => {
jest.spyOn(mockRedisConfigurationRepository, 'set');
const setConfigurationRequest: SetConfigurationRequest = {
domain: 'my-domain',
key: 'my-key',
value: 'my-value',
};
await setConfigurationUseCase.execute(
new SetConfigurationCommand(setConfigurationRequest),
);
expect(mockRedisConfigurationRepository.set).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -0,0 +1,259 @@
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) {}
async findAll(
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,
});
}
async findOneByUuid(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();
}
}
}
async findOne(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 ?
async create(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();
}
}
}
async update(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();
}
}
}
async updateWhere(
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();
}
}
}
async delete(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();
}
}
}
async deleteMany(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();
}
}
}
async findAllByQuery(
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,
});
}
async createWithFields(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();
}
}
}
async updateWithFields(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();
}
}
}
async healthCheck(): 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

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

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

View File

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

View File

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

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

View File

@ -0,0 +1,18 @@
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>;
healthCheck(): Promise<boolean>;
}

View File

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

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

@ -0,0 +1,34 @@
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 _healthCheckService: HealthCheckService,
private _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.user.health.crit',
JSON.stringify(healthCheckResult.error),
);
throw error;
}
}
}

View File

@ -0,0 +1,21 @@
syntax = "proto3";
package health;
service Health {
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
}
message HealthCheckRequest {
string service = 1;
}
message HealthCheckResponse {
enum ServingStatus {
UNKNOWN = 0;
SERVING = 1;
NOT_SERVING = 2;
}
ServingStatus status = 1;
}

View File

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

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

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

View File

@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { HealthServerController } from './adapters/primaries/health-server.controller';
import { PrismaHealthIndicatorUseCase } from './domain/usecases/prisma.health-indicator.usecase';
import { AdsRepository } from '../ad/adapters/secondaries/ads.repository';
import { DatabaseModule } from '../database/database.module';
import { HealthController } from './adapters/primaries/health.controller';
import { TerminusModule } from '@nestjs/terminus';
import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Messager } from './adapters/secondaries/messager';
@Module({
imports: [
TerminusModule,
RabbitMQModule.forRootAsync(RabbitMQModule, {
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
exchanges: [
{
name: configService.get<string>('RMQ_EXCHANGE'),
type: 'topic',
},
],
uri: configService.get<string>('RMQ_URI'),
connectionInitOptions: { wait: false },
}),
inject: [ConfigService],
}),
DatabaseModule,
],
controllers: [HealthServerController, HealthController],
providers: [PrismaHealthIndicatorUseCase, AdsRepository, Messager],
})
export class HealthModule {}

View File

@ -0,0 +1,47 @@
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('test.create.info', 'my-test');
expect(mockAmqpConnection.publish).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,58 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase';
import { HealthCheckError, HealthIndicatorResult } from '@nestjs/terminus';
import { AdsRepository } from '../../../ad/adapters/secondaries/ads.repository';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
const mockAdsRepository = {
healthCheck: jest
.fn()
.mockImplementationOnce(() => {
return Promise.resolve(true);
})
.mockImplementation(() => {
throw new PrismaClientKnownRequestError('Service unavailable', {
code: 'code',
clientVersion: 'version',
});
}),
};
describe('PrismaHealthIndicatorUseCase', () => {
let prismaHealthIndicatorUseCase: PrismaHealthIndicatorUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: AdsRepository,
useValue: mockAdsRepository,
},
PrismaHealthIndicatorUseCase,
],
}).compile();
prismaHealthIndicatorUseCase = module.get<PrismaHealthIndicatorUseCase>(
PrismaHealthIndicatorUseCase,
);
});
it('should be defined', () => {
expect(prismaHealthIndicatorUseCase).toBeDefined();
});
describe('execute', () => {
it('should check health successfully', async () => {
const healthIndicatorResult: HealthIndicatorResult =
await prismaHealthIndicatorUseCase.isHealthy('prisma');
expect(healthIndicatorResult['prisma'].status).toBe('up');
});
it('should throw an error if database is unavailable', async () => {
await expect(
prismaHealthIndicatorUseCase.isHealthy('prisma'),
).rejects.toBeInstanceOf(HealthCheckError);
});
});
});

View File

@ -0,0 +1,14 @@
import { Injectable, ValidationPipe } from '@nestjs/common';
import { RpcException } from '@nestjs/microservices';
@Injectable()
export class RpcValidationPipe extends ValidationPipe {
createExceptionFactory() {
return (validationErrors = []) => {
return new RpcException({
code: 3,
message: this.flattenValidationErrors(validationErrors),
});
};
}
}

View File

@ -0,0 +1,20 @@
import { ArgumentMetadata } from '@nestjs/common';
import { RpcValidationPipe } from '../../pipes/rpc.validation-pipe';
import { FindAdByUuidRequest } from '../../../modules/ad/domain/dtos/find-ad-by-uuid.request';
describe('RpcValidationPipe', () => {
it('should not validate request', async () => {
const target: RpcValidationPipe = new RpcValidationPipe({
whitelist: true,
forbidUnknownValues: false,
});
const metadata: ArgumentMetadata = {
type: 'body',
metatype: FindAdByUuidRequest,
data: '',
};
await target.transform(<FindAdByUuidRequest>{}, metadata).catch((err) => {
expect(err.message).toEqual('Rpc Exception');
});
});
});