Merge branch 'hexagon' into 'main'

Hexagon; v1.0.0

See merge request v3/service/user!40
This commit is contained in:
Sylvain Briat 2023-07-24 13:51:41 +00:00
commit 8e07b3b02a
106 changed files with 3307 additions and 4228 deletions

View File

@ -7,9 +7,9 @@ HEALTH_SERVICE_PORT=6001
# PRISMA
DATABASE_URL="postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=user"
# RABBIT MQ
RMQ_URI=amqp://v3-broker:5672
RMQ_EXCHANGE=mobicoop
# MESSAGE BROKER
MESSAGE_BROKER_URI=amqp://v3-broker:5672
MESSAGE_BROKER_EXCHANGE=mobicoop
# REDIS
REDIS_HOST=v3-redis

View File

@ -48,24 +48,15 @@ npm run migrate
The app exposes the following [gRPC](https://grpc.io/) services :
- **FindByUuid** : find a user by its uuid
- **FindById** : find a user by its id
```json
{
"uuid": "80126a61-d128-4f96-afdb-92e33c75a3e1"
"id": "80126a61-d128-4f96-afdb-92e33c75a3e1"
}
```
- **FindAll** : find all users; you can use pagination with `page` (default:_1_) and `perPage` (default:_10_)
```json
{
"page": 1,
"perPage": 10
}
```
- **Create** : create a user (note that uuid is optional, a uuid will be automatically attributed if it is not provided)
- **Create** : create a user
```json
{
@ -82,15 +73,15 @@ The app exposes the following [gRPC](https://grpc.io/) services :
{
"firstName": "Jezabel-Katarina",
"email": "jezabel-katarina.doe@email.com",
"uuid": "30f49838-3f24-42bb-a489-8ffb480173ae"
"id": "30f49838-3f24-42bb-a489-8ffb480173ae"
}
```
- **Delete** : delete a user by its uuid
- **Delete** : delete a user by its id
```json
{
"uuid": "80126a61-d128-4f96-afdb-92e33c75a3e1"
"id": "80126a61-d128-4f96-afdb-92e33c75a3e1"
}
```
@ -102,7 +93,7 @@ As mentionned earlier, RabbitMQ messages are sent after these events :
- **Update** (message : the updated user informations)
- **Delete** (message : the uuid of the deleted user)
- **Delete** (message : the id of the deleted user)
Various messages are also sent for logging purpose.

2828
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "@mobicoop/user",
"version": "0.0.1",
"version": "1.0.0",
"description": "Mobicoop V3 User Service",
"author": "sbriat",
"private": true,
@ -17,33 +17,32 @@
"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": "npm run test:unit && npm run test:integration",
"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:integration": "npm run migrate:test && dotenv -e .env.test -- jest --testPathPattern 'tests/integration/' --verbose --runInBand",
"test:integration:ci": "npm run migrate:test:ci && dotenv -e ci/.env.ci -- jest --testPathPattern 'tests/integration/' --runInBand",
"test:cov": "jest --testPathPattern 'tests/unit/' --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json",
"generate": "docker exec v3-user-api sh -c 'npx prisma generate'",
"migrate": "docker exec v3-user-api sh -c 'npx prisma migrate dev'",
"migrate": "docker exec v3-auth-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",
"@grpc/grpc-js": "^1.8.0",
"@grpc/proto-loader": "^0.7.4",
"@liaoliaots/nestjs-redis": "^9.0.5",
"@mobicoop/configuration-module": "^1.1.0",
"@mobicoop/message-broker-module": "^1.0.5",
"@mobicoop/configuration-module": "^1.2.0",
"@mobicoop/ddd-library": "^1.0.0",
"@mobicoop/health-module": "^2.0.0",
"@mobicoop/message-broker-module": "^1.2.0",
"@nestjs/cache-manager": "^1.0.0",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.0.0",
"@nestjs/cqrs": "^9.0.1",
"@nestjs/event-emitter": "^2.0.0",
"@nestjs/microservices": "^9.2.1",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/terminus": "^9.2.2",
@ -54,6 +53,7 @@
"cache-manager": "^5.2.1",
"cache-manager-ioredis-yet": "^1.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"dotenv-cli": "^6.0.0",
"ioredis": "^5.3.0"
},
@ -63,6 +63,7 @@
"@nestjs/testing": "^9.0.0",
"@types/jest": "^29.2.5",
"@types/node": "^18.11.18",
"@types/uuid": "^9.0.2",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
@ -84,12 +85,13 @@
"ts"
],
"modulePathIgnorePatterns": [
".controller.ts",
".module.ts",
".request.ts",
".presenter.ts",
".profile.ts",
".exception.ts",
".dto.ts",
".constants.ts",
".di-tokens.ts",
".response.ts",
".port.ts",
"prisma.service.ts",
"main.ts"
],
"rootDir": "src",
@ -101,15 +103,20 @@
"**/*.(t|j)s"
],
"coveragePathIgnorePatterns": [
".controller.ts",
".module.ts",
".request.ts",
".presenter.ts",
".profile.ts",
".exception.ts",
".dto.ts",
".constants.ts",
".di-tokens.ts",
".response.ts",
".port.ts",
"prisma.service.ts",
"main.ts"
],
"coverageDirectory": "../coverage",
"moduleNameMapper": {
"^@modules(.*)": "<rootDir>/modules/$1",
"^@src(.*)": "<rootDir>$1"
},
"testEnvironment": "node"
}
}

View File

@ -1,2 +0,0 @@
export const MESSAGE_BROKER_PUBLISHER = Symbol();
export const MESSAGE_PUBLISHER = Symbol();

View File

@ -1,70 +1,67 @@
import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { HealthModule } from './modules/health/health.module';
import { UserModule } from './modules/user/user.module';
import {
ConfigurationModule,
ConfigurationModuleOptions,
} from '@mobicoop/configuration-module';
import {
MessageBrokerModule,
MessageBrokerModuleOptions,
} from '@mobicoop/message-broker-module';
HealthModule,
HealthModuleOptions,
HealthRepositoryPort,
} from '@mobicoop/health-module';
import { MessagerModule } from './modules/messager/messager.module';
import { USER_REPOSITORY } from './modules/user/user.di-tokens';
import { MESSAGE_PUBLISHER } from './modules/messager/messager.di-tokens';
import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { EventEmitterModule } from '@nestjs/event-emitter';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
AutomapperModule.forRoot({ strategyInitializer: classes() }),
UserModule,
MessageBrokerModule.forRootAsync(
{
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (
configService: ConfigService,
): Promise<MessageBrokerModuleOptions> => ({
EventEmitterModule.forRoot(),
ConfigurationModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (
configService: ConfigService,
): Promise<ConfigurationModuleOptions> => ({
domain: configService.get<string>('SERVICE_CONFIGURATION_DOMAIN'),
messageBroker: {
uri: configService.get<string>('MESSAGE_BROKER_URI'),
exchange: configService.get<string>('MESSAGE_BROKER_EXCHANGE'),
handlers: {},
}),
},
false,
),
ConfigurationModule.forRootAsync(
{
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (
configService: ConfigService,
): Promise<ConfigurationModuleOptions> => ({
domain: configService.get<string>('SERVICE_CONFIGURATION_DOMAIN'),
messageBroker: {
uri: configService.get<string>('MESSAGE_BROKER_URI'),
exchange: configService.get<string>('MESSAGE_BROKER_EXCHANGE'),
},
redis: {
host: configService.get<string>('REDIS_HOST'),
password: configService.get<string>('REDIS_PASSWORD'),
port: configService.get<number>('REDIS_PORT'),
},
setConfigurationBrokerQueue: 'user-configuration-create-update',
deleteConfigurationQueue: 'user-configuration-delete',
propagateConfigurationQueue: 'user-configuration-propagate',
}),
}),
HealthModule.forRootAsync({
imports: [UserModule, MessagerModule],
inject: [USER_REPOSITORY, MESSAGE_PUBLISHER],
useFactory: async (
userRepository: HealthRepositoryPort,
messagePublisher: MessagePublisherPort,
): Promise<HealthModuleOptions> => ({
serviceName: 'user',
criticalLoggingKey: 'logging.user.health.crit',
checkRepositories: [
{
name: 'UserRepository',
repository: userRepository,
},
redis: {
host: configService.get<string>('REDIS_HOST'),
password: configService.get<string>('REDIS_PASSWORD'),
port: configService.get<number>('REDIS_PORT'),
},
setConfigurationBrokerRoutingKeys: [
'configuration.create',
'configuration.update',
],
deleteConfigurationRoutingKey: 'configuration.delete',
propagateConfigurationRoutingKey: 'configuration.propagate',
setConfigurationBrokerQueue: 'user-configuration-create-update',
deleteConfigurationQueue: 'user-configuration-delete',
propagateConfigurationQueue: 'user-configuration-propagate',
}),
},
true,
),
HealthModule,
],
messagePublisher,
}),
}),
UserModule,
MessagerModule,
],
controllers: [],
providers: [],
exports: [UserModule, MessagerModule],
})
export class AppModule {}

View File

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

View File

@ -1,3 +0,0 @@
export interface IPublishMessage {
publish(routingKey: string, message: string): void;
}

View File

@ -13,8 +13,8 @@ async function bootstrap() {
options: {
package: ['user', 'health'],
protoPath: [
join(__dirname, 'modules/user/adapters/primaries/user.proto'),
join(__dirname, 'modules/health/adapters/primaries/health.proto'),
join(__dirname, 'modules/user/interface/grpc-controllers/user.proto'),
join(__dirname, 'health.proto'),
],
url: process.env.SERVICE_URL + ':' + process.env.SERVICE_PORT,
loader: { keepCase: true },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,36 +0,0 @@
import { Controller, Get, Inject } from '@nestjs/common';
import {
HealthCheckService,
HealthCheck,
HealthCheckResult,
} from '@nestjs/terminus';
import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase';
import { MESSAGE_PUBLISHER } from 'src/app.constants';
import { IPublishMessage } from 'src/interfaces/message-publisher';
@Controller('health')
export class HealthController {
constructor(
private readonly prismaHealthIndicatorUseCase: PrismaHealthIndicatorUseCase,
private readonly healthCheckService: HealthCheckService,
@Inject(MESSAGE_PUBLISHER)
private readonly messagePublisher: IPublishMessage,
) {}
@Get()
@HealthCheck()
async check() {
try {
return await this.healthCheckService.check([
async () => this.prismaHealthIndicatorUseCase.isHealthy('prisma'),
]);
} catch (error) {
const healthCheckResult: HealthCheckResult = error.response;
this.messagePublisher.publish(
'logging.user.health.crit',
JSON.stringify(healthCheckResult.error),
);
throw error;
}
}
}

View File

@ -1,16 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { MESSAGE_BROKER_PUBLISHER } from '../../../../app.constants';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
import { IPublishMessage } from 'src/interfaces/message-publisher';
@Injectable()
export class MessagePublisher implements IPublishMessage {
constructor(
@Inject(MESSAGE_BROKER_PUBLISHER)
private readonly messageBrokerPublisher: MessageBrokerPublisher,
) {}
publish = (routingKey: string, message: string): void => {
this.messageBrokerPublisher.publish(routingKey, message);
};
}

View File

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

View File

@ -1,28 +0,0 @@
import { Module } from '@nestjs/common';
import { HealthServerController } from './adapters/primaries/health-server.controller';
import { PrismaHealthIndicatorUseCase } from './domain/usecases/prisma.health-indicator.usecase';
import { UsersRepository } from '../user/adapters/secondaries/users.repository';
import { DatabaseModule } from '../database/database.module';
import { HealthController } from './adapters/primaries/health.controller';
import { TerminusModule } from '@nestjs/terminus';
import { MESSAGE_BROKER_PUBLISHER, MESSAGE_PUBLISHER } from 'src/app.constants';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
import { MessagePublisher } from './adapters/secondaries/message-publisher';
@Module({
imports: [TerminusModule, DatabaseModule],
controllers: [HealthServerController, HealthController],
providers: [
PrismaHealthIndicatorUseCase,
UsersRepository,
{
provide: MESSAGE_BROKER_PUBLISHER,
useClass: MessageBrokerPublisher,
},
{
provide: MESSAGE_PUBLISHER,
useClass: MessagePublisher,
},
],
})
export class HealthModule {}

View File

@ -1,36 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MessagePublisher } from '../../adapters/secondaries/message-publisher';
import { MESSAGE_BROKER_PUBLISHER } from '../../../../app.constants';
const mockMessageBrokerPublisher = {
publish: jest.fn().mockImplementation(),
};
describe('Messager', () => {
let messagePublisher: MessagePublisher;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
MessagePublisher,
{
provide: MESSAGE_BROKER_PUBLISHER,
useValue: mockMessageBrokerPublisher,
},
],
}).compile();
messagePublisher = module.get<MessagePublisher>(MessagePublisher);
});
it('should be defined', () => {
expect(messagePublisher).toBeDefined();
});
it('should publish a message', async () => {
jest.spyOn(mockMessageBrokerPublisher, 'publish');
messagePublisher.publish('health.info', 'my-test');
expect(mockMessageBrokerPublisher.publish).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,58 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase';
import { UsersRepository } from '../../../user/adapters/secondaries/users.repository';
import { HealthCheckError, HealthIndicatorResult } from '@nestjs/terminus';
import { Prisma } from '@prisma/client';
const mockUsersRepository = {
healthCheck: jest
.fn()
.mockImplementationOnce(() => {
return Promise.resolve(true);
})
.mockImplementation(() => {
throw new Prisma.PrismaClientKnownRequestError('Service unavailable', {
code: 'code',
clientVersion: 'version',
});
}),
};
describe('PrismaHealthIndicatorUseCase', () => {
let prismaHealthIndicatorUseCase: PrismaHealthIndicatorUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: UsersRepository,
useValue: mockUsersRepository,
},
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 @@
export const MESSAGE_PUBLISHER = Symbol('MESSAGE_PUBLISHER');

View File

@ -0,0 +1,36 @@
import { Module, Provider } from '@nestjs/common';
import { MESSAGE_PUBLISHER } from './messager.di-tokens';
import {
MessageBrokerModule,
MessageBrokerModuleOptions,
MessageBrokerPublisher,
} from '@mobicoop/message-broker-module';
import { ConfigModule, ConfigService } from '@nestjs/config';
const imports = [
MessageBrokerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (
configService: ConfigService,
): Promise<MessageBrokerModuleOptions> => ({
uri: configService.get<string>('MESSAGE_BROKER_URI'),
exchange: configService.get<string>('MESSAGE_BROKER_EXCHANGE'),
name: 'user',
}),
}),
];
const providers: Provider[] = [
{
provide: MESSAGE_PUBLISHER,
useClass: MessageBrokerPublisher,
},
];
@Module({
imports,
providers,
exports: [MESSAGE_PUBLISHER],
})
export class MessagerModule {}

View File

@ -1,121 +0,0 @@
import { Mapper } from '@automapper/core';
import { InjectMapper } from '@automapper/nestjs';
import { Controller, UseInterceptors, UsePipes } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { DatabaseException } from '../../../database/exceptions/database.exception';
import { CreateUserCommand } from '../../commands/create-user.command';
import { DeleteUserCommand } from '../../commands/delete-user.command';
import { UpdateUserCommand } from '../../commands/update-user.command';
import { CreateUserRequest } from '../../domain/dtos/create-user.request';
import { FindAllUsersRequest } from '../../domain/dtos/find-all-users.request';
import { FindUserByUuidRequest } from '../../domain/dtos/find-user-by-uuid.request';
import { UpdateUserRequest } from '../../domain/dtos/update-user.request';
import { User } from '../../domain/entities/user';
import { FindAllUsersQuery } from '../../queries/find-all-users.query';
import { FindUserByUuidQuery } from '../../queries/find-user-by-uuid.query';
import { UserPresenter } from './user.presenter';
import { ICollection } from '../../../database/interfaces/collection.interface';
import { RpcValidationPipe } from '../../../../utils/pipes/rpc.validation-pipe';
import { CacheInterceptor, CacheKey } from '@nestjs/cache-manager';
@UsePipes(
new RpcValidationPipe({
whitelist: true,
forbidUnknownValues: false,
}),
)
@Controller()
export class UserController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
@InjectMapper() private readonly mapper: Mapper,
) {}
@GrpcMethod('UsersService', 'FindAll')
@UseInterceptors(CacheInterceptor)
@CacheKey('UsersServiceFindAll')
async findAll(data: FindAllUsersRequest): Promise<ICollection<User>> {
const userCollection = await this.queryBus.execute(
new FindAllUsersQuery(data),
);
return Promise.resolve({
data: userCollection.data.map((user: User) =>
this.mapper.map(user, User, UserPresenter),
),
total: userCollection.total,
});
}
@GrpcMethod('UsersService', 'FindOneByUuid')
@UseInterceptors(CacheInterceptor)
@CacheKey('UsersServiceFindOneByUuid')
async findOneByUuid(data: FindUserByUuidRequest): Promise<UserPresenter> {
try {
const user = await this.queryBus.execute(new FindUserByUuidQuery(data));
return this.mapper.map(user, User, UserPresenter);
} catch (error) {
throw new RpcException({
code: 5,
message: 'User not found',
});
}
}
@GrpcMethod('UsersService', 'Create')
async createUser(data: CreateUserRequest): Promise<UserPresenter> {
try {
const user = await this.commandBus.execute(new CreateUserCommand(data));
return this.mapper.map(user, User, UserPresenter);
} catch (e) {
if (e instanceof DatabaseException) {
if (e.message.includes('Already exists')) {
throw new RpcException({
code: 6,
message: 'User already exists',
});
}
}
throw new RpcException({});
}
}
@GrpcMethod('UsersService', 'Update')
async updateUser(data: UpdateUserRequest): Promise<UserPresenter> {
try {
const user = await this.commandBus.execute(new UpdateUserCommand(data));
return this.mapper.map(user, User, UserPresenter);
} catch (e) {
if (e instanceof DatabaseException) {
if (e.message.includes('not found')) {
throw new RpcException({
code: 5,
message: 'User not found',
});
}
}
throw new RpcException({});
}
}
@GrpcMethod('UsersService', 'Delete')
async deleteUser(data: FindUserByUuidRequest): Promise<void> {
try {
await this.commandBus.execute(new DeleteUserCommand(data.uuid));
return Promise.resolve();
} catch (e) {
if (e instanceof DatabaseException) {
if (e.message.includes('not found')) {
throw new RpcException({
code: 5,
message: 'User not found',
});
}
}
throw new RpcException({});
}
}
}

View File

@ -1,18 +0,0 @@
import { AutoMap } from '@automapper/classes';
export class UserPresenter {
@AutoMap()
uuid: string;
@AutoMap()
firstName: string;
@AutoMap()
lastName: string;
@AutoMap()
email: string;
@AutoMap()
phone: string;
}

View File

@ -1,16 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { IPublishMessage } from '../../../../interfaces/message-publisher';
import { MESSAGE_BROKER_PUBLISHER } from '../../../../app.constants';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
@Injectable()
export class MessagePublisher implements IPublishMessage {
constructor(
@Inject(MESSAGE_BROKER_PUBLISHER)
private readonly messageBrokerPublisher: MessageBrokerPublisher,
) {}
publish = (routingKey: string, message: string): void => {
this.messageBrokerPublisher.publish(routingKey, message);
};
}

View File

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

View File

@ -1,9 +0,0 @@
import { CreateUserRequest } from '../domain/dtos/create-user.request';
export class CreateUserCommand {
readonly createUserRequest: CreateUserRequest;
constructor(request: CreateUserRequest) {
this.createUserRequest = request;
}
}

View File

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

View File

@ -1,9 +0,0 @@
import { UpdateUserRequest } from '../domain/dtos/update-user.request';
export class UpdateUserCommand {
readonly updateUserRequest: UpdateUserRequest;
constructor(request: UpdateUserRequest) {
this.updateUserRequest = request;
}
}

View File

@ -0,0 +1,16 @@
import { Command, CommandProps } from '@mobicoop/ddd-library';
export class CreateUserCommand extends Command {
readonly firstName?: string;
readonly lastName?: string;
readonly email?: string;
readonly phone?: string;
constructor(props: CommandProps<CreateUserCommand>) {
super(props);
this.firstName = props.firstName;
this.lastName = props.lastName;
this.email = props.email;
this.phone = props.phone;
}
}

View File

@ -0,0 +1,55 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import {
AggregateID,
ConflictException,
UniqueConstraintException,
} from '@mobicoop/ddd-library';
import { CreateUserCommand } from './create-user.command';
import { USER_REPOSITORY } from '@modules/user/user.di-tokens';
import { UserRepositoryPort } from '../../ports/user.repository.port';
import { UserEntity } from '../../../domain/user.entity';
import {
EmailAlreadyExistsException,
PhoneAlreadyExistsException,
UserAlreadyExistsException,
} from '../../../domain/user.errors';
@CommandHandler(CreateUserCommand)
export class CreateUserService implements ICommandHandler {
constructor(
@Inject(USER_REPOSITORY)
private readonly repository: UserRepositoryPort,
) {}
async execute(command: CreateUserCommand): Promise<AggregateID> {
const user = UserEntity.create({
firstName: command.firstName,
lastName: command.lastName,
email: command.email,
phone: command.phone,
});
try {
await this.repository.insert(user);
return user.id;
} catch (error: any) {
if (error instanceof ConflictException) {
throw new UserAlreadyExistsException(error);
}
if (
error instanceof UniqueConstraintException &&
error.message.includes('email')
) {
throw new EmailAlreadyExistsException(error);
}
if (
error instanceof UniqueConstraintException &&
error.message.includes('phone')
) {
throw new PhoneAlreadyExistsException(error);
}
throw error;
}
}
}

View File

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

View File

@ -0,0 +1,21 @@
import { Inject } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { DeleteUserCommand } from './delete-user.command';
import { USER_REPOSITORY } from '@modules/user/user.di-tokens';
import { UserRepositoryPort } from '../../ports/user.repository.port';
import { UserEntity } from '@modules/user/core/domain/user.entity';
@CommandHandler(DeleteUserCommand)
export class DeleteUserService implements ICommandHandler {
constructor(
@Inject(USER_REPOSITORY)
private readonly userRepository: UserRepositoryPort,
) {}
async execute(command: DeleteUserCommand): Promise<boolean> {
const user: UserEntity = await this.userRepository.findOneById(command.id);
user.delete();
const isDeleted: boolean = await this.userRepository.delete(user);
return isDeleted;
}
}

View File

@ -0,0 +1,16 @@
import { Command, CommandProps } from '@mobicoop/ddd-library';
export class UpdateUserCommand extends Command {
readonly firstName?: string;
readonly lastName?: string;
readonly email?: string;
readonly phone?: string;
constructor(props: CommandProps<UpdateUserCommand>) {
super(props);
this.firstName = props.firstName;
this.lastName = props.lastName;
this.email = props.email;
this.phone = props.phone;
}
}

View File

@ -0,0 +1,49 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { AggregateID, UniqueConstraintException } from '@mobicoop/ddd-library';
import { USER_REPOSITORY } from '@modules/user/user.di-tokens';
import { UserRepositoryPort } from '../../ports/user.repository.port';
import { UserEntity } from '../../../domain/user.entity';
import { UpdateUserCommand } from './update-user.command';
import {
EmailAlreadyExistsException,
PhoneAlreadyExistsException,
} from '@modules/user/core/domain/user.errors';
@CommandHandler(UpdateUserCommand)
export class UpdateUserService implements ICommandHandler {
constructor(
@Inject(USER_REPOSITORY)
private readonly userRepository: UserRepositoryPort,
) {}
async execute(command: UpdateUserCommand): Promise<AggregateID> {
try {
const user: UserEntity = await this.userRepository.findOneById(
command.id,
);
user.update({
firstName: command.firstName,
lastName: command.lastName,
email: command.email,
phone: command.phone,
});
await this.userRepository.update(user.id, user);
return user.id;
} catch (error: any) {
if (
error instanceof UniqueConstraintException &&
error.message.includes('email')
) {
throw new EmailAlreadyExistsException(error);
}
if (
error instanceof UniqueConstraintException &&
error.message.includes('phone')
) {
throw new PhoneAlreadyExistsException(error);
}
throw error;
}
}
}

View File

@ -0,0 +1,31 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { USER_MESSAGE_PUBLISHER } from '@modules/user/user.di-tokens';
import { UserCreatedDomainEvent } from '../../domain/events/user-created.domain-event';
import { UserCreatedIntegrationEvent } from '../events/user-created.integration-event';
import { USER_CREATED_ROUTING_KEY } from '@modules/user/user.constants';
@Injectable()
export class PublishMessageWhenUserIsCreatedDomainEventHandler {
constructor(
@Inject(USER_MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
) {}
@OnEvent(UserCreatedDomainEvent.name, { async: true, promisify: true })
async handle(event: UserCreatedDomainEvent): Promise<any> {
const userCreatedIntegrationEvent = new UserCreatedIntegrationEvent({
id: event.aggregateId,
firstName: event.firstName,
lastName: event.lastName,
email: event.email,
phone: event.phone,
metadata: event.metadata,
});
this.messagePublisher.publish(
USER_CREATED_ROUTING_KEY,
JSON.stringify(userCreatedIntegrationEvent),
);
}
}

View File

@ -0,0 +1,27 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { USER_MESSAGE_PUBLISHER } from '@modules/user/user.di-tokens';
import { UserDeletedDomainEvent } from '../../domain/events/user-deleted.domain-event';
import { UserDeletedIntegrationEvent } from '../events/user-deleted.integration-event';
import { USER_DELETED_ROUTING_KEY } from '@modules/user/user.constants';
@Injectable()
export class PublishMessageWhenUserIsDeletedDomainEventHandler {
constructor(
@Inject(USER_MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
) {}
@OnEvent(UserDeletedDomainEvent.name, { async: true, promisify: true })
async handle(event: UserDeletedDomainEvent): Promise<any> {
const userDeletedIntegrationEvent = new UserDeletedIntegrationEvent({
id: event.aggregateId,
metadata: event.metadata,
});
this.messagePublisher.publish(
USER_DELETED_ROUTING_KEY,
JSON.stringify(userDeletedIntegrationEvent),
);
}
}

View File

@ -0,0 +1,31 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { USER_MESSAGE_PUBLISHER } from '@modules/user/user.di-tokens';
import { UserUpdatedDomainEvent } from '../../domain/events/user-updated.domain-event';
import { UserUpdatedIntegrationEvent } from '../events/user-updated.integration-event';
import { USER_UPDATED_ROUTING_KEY } from '@modules/user/user.constants';
@Injectable()
export class PublishMessageWhenUserIsUpdatedDomainEventHandler {
constructor(
@Inject(USER_MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
) {}
@OnEvent(UserUpdatedDomainEvent.name, { async: true, promisify: true })
async handle(event: UserUpdatedDomainEvent): Promise<any> {
const userUpdatedIntegrationEvent = new UserUpdatedIntegrationEvent({
id: event.aggregateId,
firstName: event.firstName,
lastName: event.lastName,
email: event.email,
phone: event.phone,
metadata: event.metadata,
});
this.messagePublisher.publish(
USER_UPDATED_ROUTING_KEY,
JSON.stringify(userUpdatedIntegrationEvent),
);
}
}

View File

@ -0,0 +1,16 @@
import { IntegrationEvent, IntegrationEventProps } from '@mobicoop/ddd-library';
export class UserCreatedIntegrationEvent extends IntegrationEvent {
readonly firstName: string;
readonly lastName: string;
readonly email: string;
readonly phone: string;
constructor(props: IntegrationEventProps<UserCreatedIntegrationEvent>) {
super(props);
this.firstName = props.firstName;
this.lastName = props.lastName;
this.email = props.email;
this.phone = props.phone;
}
}

View File

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

View File

@ -0,0 +1,16 @@
import { IntegrationEvent, IntegrationEventProps } from '@mobicoop/ddd-library';
export class UserUpdatedIntegrationEvent extends IntegrationEvent {
readonly firstName: string;
readonly lastName: string;
readonly email: string;
readonly phone: string;
constructor(props: IntegrationEventProps<UserUpdatedIntegrationEvent>) {
super(props);
this.firstName = props.firstName;
this.lastName = props.lastName;
this.email = props.email;
this.phone = props.phone;
}
}

View File

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

View File

@ -0,0 +1,17 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { FindUserByIdQuery } from './find-user-by-id.query';
import { Inject } from '@nestjs/common';
import { USER_REPOSITORY } from '@modules/user/user.di-tokens';
import { UserRepositoryPort } from '../../ports/user.repository.port';
import { UserEntity } from '@modules/user/core/domain/user.entity';
@QueryHandler(FindUserByIdQuery)
export class FindUserByIdQueryHandler implements IQueryHandler {
constructor(
@Inject(USER_REPOSITORY)
private readonly userRepository: UserRepositoryPort,
) {}
async execute(query: FindUserByIdQuery): Promise<UserEntity> {
return await this.userRepository.findOneById(query.id);
}
}

View File

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

View File

@ -0,0 +1,16 @@
import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library';
export class UserCreatedDomainEvent extends DomainEvent {
readonly firstName: string;
readonly lastName: string;
readonly email: string;
readonly phone: string;
constructor(props: DomainEventProps<UserCreatedDomainEvent>) {
super(props);
this.firstName = props.firstName;
this.lastName = props.lastName;
this.email = props.email;
this.phone = props.phone;
}
}

View File

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

View File

@ -0,0 +1,16 @@
import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library';
export class UserUpdatedDomainEvent extends DomainEvent {
readonly firstName: string;
readonly lastName: string;
readonly email: string;
readonly phone: string;
constructor(props: DomainEventProps<UserUpdatedDomainEvent>) {
super(props);
this.firstName = props.firstName;
this.lastName = props.lastName;
this.email = props.email;
this.phone = props.phone;
}
}

View File

@ -0,0 +1,55 @@
import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library';
import { v4 } from 'uuid';
import { CreateUserProps, UpdateUserProps, UserProps } from './user.types';
import { UserCreatedDomainEvent } from './events/user-created.domain-event';
import { UserUpdatedDomainEvent } from './events/user-updated.domain-event';
import { UserDeletedDomainEvent } from './events/user-deleted.domain-event';
export class UserEntity extends AggregateRoot<UserProps> {
protected readonly _id: AggregateID;
static create = (create: CreateUserProps): UserEntity => {
const id = v4();
const props: UserProps = { ...create };
const user = new UserEntity({ id, props });
user.addEvent(
new UserCreatedDomainEvent({
aggregateId: id,
firstName: props.firstName,
lastName: props.lastName,
email: props.email,
phone: props.phone,
}),
);
return user;
};
update(props: UpdateUserProps): void {
this.props.firstName = props.firstName ?? this.props.firstName;
this.props.lastName = props.lastName ?? this.props.lastName;
this.props.email = props.email ?? this.props.email;
this.props.phone = props.phone ?? this.props.phone;
this.addEvent(
new UserUpdatedDomainEvent({
aggregateId: this._id,
firstName: props.firstName,
lastName: props.lastName,
email: props.email,
phone: props.phone,
}),
);
}
delete(): void {
this.addEvent(
new UserDeletedDomainEvent({
aggregateId: this.id,
}),
);
}
validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database
}
}

View File

@ -0,0 +1,31 @@
import { ExceptionBase } from '@mobicoop/ddd-library';
export class UserAlreadyExistsException extends ExceptionBase {
static readonly message = 'User already exists';
public readonly code = 'USER.ALREADY_EXISTS';
constructor(cause?: Error, metadata?: unknown) {
super(UserAlreadyExistsException.message, cause, metadata);
}
}
export class EmailAlreadyExistsException extends ExceptionBase {
static readonly message = 'Email already exists';
public readonly code = 'EMAIL.ALREADY_EXISTS';
constructor(cause?: Error, metadata?: unknown) {
super(EmailAlreadyExistsException.message, cause, metadata);
}
}
export class PhoneAlreadyExistsException extends ExceptionBase {
static readonly message = 'Phone already exists';
public readonly code = 'PHONE.ALREADY_EXISTS';
constructor(cause?: Error, metadata?: unknown) {
super(PhoneAlreadyExistsException.message, cause, metadata);
}
}

View File

@ -0,0 +1,22 @@
// All properties that a User has
export interface UserProps {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
}
// Properties that are needed for a User creation
export interface CreateUserProps {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
}
export interface UpdateUserProps {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
}

View File

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

View File

@ -1,35 +0,0 @@
import { AutoMap } from '@automapper/classes';
import {
IsEmail,
IsNotEmpty,
IsOptional,
IsPhoneNumber,
IsString,
} from 'class-validator';
export class UpdateUserRequest {
@IsString()
@IsNotEmpty()
@AutoMap()
uuid: string;
@IsString()
@IsOptional()
@AutoMap()
firstName?: string;
@IsString()
@IsOptional()
@AutoMap()
lastName?: string;
@IsEmail()
@IsOptional()
@AutoMap()
email?: string;
@IsPhoneNumber()
@IsOptional()
@AutoMap()
phone?: string;
}

View File

@ -1,18 +0,0 @@
import { AutoMap } from '@automapper/classes';
export class User {
@AutoMap()
uuid: string;
@AutoMap()
firstName?: string;
@AutoMap()
lastName?: string;
@AutoMap()
email?: string;
@AutoMap()
phone?: string;
}

View File

@ -1,51 +0,0 @@
import { Mapper } from '@automapper/core';
import { InjectMapper } from '@automapper/nestjs';
import { CommandHandler } from '@nestjs/cqrs';
import { UsersRepository } from '../../adapters/secondaries/users.repository';
import { CreateUserCommand } from '../../commands/create-user.command';
import { CreateUserRequest } from '../dtos/create-user.request';
import { User } from '../entities/user';
import { Inject } from '@nestjs/common';
import { MESSAGE_PUBLISHER } from '../../../../app.constants';
import { IPublishMessage } from '../../../../interfaces/message-publisher';
@CommandHandler(CreateUserCommand)
export class CreateUserUseCase {
constructor(
private readonly repository: UsersRepository,
@Inject(MESSAGE_PUBLISHER)
private readonly messagePublisher: IPublishMessage,
@InjectMapper() private readonly mapper: Mapper,
) {}
execute = async (command: CreateUserCommand): Promise<User> => {
const entity = this.mapper.map(
command.createUserRequest,
CreateUserRequest,
User,
);
try {
const user = await this.repository.create(entity);
this.messagePublisher.publish('user.create', JSON.stringify(user));
this.messagePublisher.publish(
'logging.user.create.info',
JSON.stringify(user),
);
return user;
} catch (error) {
let key = 'logging.user.create.crit';
if (error.message.includes('Already exists')) {
key = 'logging.user.create.warning';
}
this.messagePublisher.publish(
key,
JSON.stringify({
command,
error,
}),
);
throw error;
}
};
}

View File

@ -1,40 +0,0 @@
import { CommandHandler } from '@nestjs/cqrs';
import { UsersRepository } from '../../adapters/secondaries/users.repository';
import { DeleteUserCommand } from '../../commands/delete-user.command';
import { User } from '../entities/user';
import { IPublishMessage } from '../../../../interfaces/message-publisher';
import { Inject } from '@nestjs/common';
import { MESSAGE_PUBLISHER } from '../../../../app.constants';
@CommandHandler(DeleteUserCommand)
export class DeleteUserUseCase {
constructor(
private readonly repository: UsersRepository,
@Inject(MESSAGE_PUBLISHER)
private readonly messagePublisher: IPublishMessage,
) {}
execute = async (command: DeleteUserCommand): Promise<User> => {
try {
const user = await this.repository.delete(command.uuid);
this.messagePublisher.publish(
'user.delete',
JSON.stringify({ uuid: user.uuid }),
);
this.messagePublisher.publish(
'logging.user.delete.info',
JSON.stringify({ uuid: user.uuid }),
);
return user;
} catch (error) {
this.messagePublisher.publish(
'logging.user.delete.crit',
JSON.stringify({
command,
error,
}),
);
throw error;
}
};
}

View File

@ -1,15 +0,0 @@
import { QueryHandler } from '@nestjs/cqrs';
import { ICollection } from 'src/modules/database/interfaces/collection.interface';
import { UsersRepository } from '../../adapters/secondaries/users.repository';
import { FindAllUsersQuery } from '../../queries/find-all-users.query';
import { User } from '../entities/user';
@QueryHandler(FindAllUsersQuery)
export class FindAllUsersUseCase {
constructor(private readonly repository: UsersRepository) {}
execute = async (
findAllUsersQuery: FindAllUsersQuery,
): Promise<ICollection<User>> =>
this.repository.findAll(findAllUsersQuery.page, findAllUsersQuery.perPage);
}

View File

@ -1,33 +0,0 @@
import { Inject, NotFoundException } from '@nestjs/common';
import { QueryHandler } from '@nestjs/cqrs';
import { UsersRepository } from '../../adapters/secondaries/users.repository';
import { FindUserByUuidQuery } from '../../queries/find-user-by-uuid.query';
import { User } from '../entities/user';
import { MESSAGE_PUBLISHER } from '../../../../app.constants';
import { IPublishMessage } from '../../../../interfaces/message-publisher';
@QueryHandler(FindUserByUuidQuery)
export class FindUserByUuidUseCase {
constructor(
private readonly repository: UsersRepository,
@Inject(MESSAGE_PUBLISHER)
private readonly messagePublisher: IPublishMessage,
) {}
execute = async (findUserByUuid: FindUserByUuidQuery): Promise<User> => {
try {
const user = await this.repository.findOneByUuid(findUserByUuid.uuid);
if (!user) throw new NotFoundException();
return user;
} catch (error) {
this.messagePublisher.publish(
'logging.user.read.warning',
JSON.stringify({
query: findUserByUuid,
error,
}),
);
throw error;
}
};
}

View File

@ -1,53 +0,0 @@
import { Mapper } from '@automapper/core';
import { InjectMapper } from '@automapper/nestjs';
import { CommandHandler } from '@nestjs/cqrs';
import { UsersRepository } from '../../adapters/secondaries/users.repository';
import { UpdateUserCommand } from '../../commands/update-user.command';
import { UpdateUserRequest } from '../dtos/update-user.request';
import { User } from '../entities/user';
import { MESSAGE_PUBLISHER } from '../../../../app.constants';
import { Inject } from '@nestjs/common';
import { IPublishMessage } from '../../../../interfaces/message-publisher';
@CommandHandler(UpdateUserCommand)
export class UpdateUserUseCase {
constructor(
private readonly repository: UsersRepository,
@Inject(MESSAGE_PUBLISHER)
private readonly messagePublisher: IPublishMessage,
@InjectMapper() private readonly mapper: Mapper,
) {}
execute = async (command: UpdateUserCommand): Promise<User> => {
const entity = this.mapper.map(
command.updateUserRequest,
UpdateUserRequest,
User,
);
try {
const user = await this.repository.update(
command.updateUserRequest.uuid,
entity,
);
this.messagePublisher.publish(
'user.update',
JSON.stringify(command.updateUserRequest),
);
this.messagePublisher.publish(
'logging.user.update.info',
JSON.stringify(command.updateUserRequest),
);
return user;
} catch (error) {
this.messagePublisher.publish(
'logging.user.update.crit',
JSON.stringify({
command,
error,
}),
);
throw error;
}
};
}

View File

@ -0,0 +1,56 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
LoggerBase,
MessagePublisherPort,
PrismaRepositoryBase,
} from '@mobicoop/ddd-library';
import { PrismaService } from './prisma.service';
import { UserEntity } from '../core/domain/user.entity';
import { UserRepositoryPort } from '../core/application/ports/user.repository.port';
import { UserMapper } from '../user.mapper';
import { USER_MESSAGE_PUBLISHER } from '../user.di-tokens';
export type UserBaseModel = {
uuid: string;
firstName: string;
lastName: string;
email: string;
phone: string;
};
export type UserReadModel = UserBaseModel & {
createdAt?: Date;
updatedAt?: Date;
};
export type UserWriteModel = UserBaseModel;
/**
* Repository is used for retrieving/saving domain entities
* */
@Injectable()
export class UserRepository
extends PrismaRepositoryBase<UserEntity, UserReadModel, UserWriteModel>
implements UserRepositoryPort
{
constructor(
prisma: PrismaService,
mapper: UserMapper,
eventEmitter: EventEmitter2,
@Inject(USER_MESSAGE_PUBLISHER)
protected readonly messagePublisher: MessagePublisherPort,
) {
super(
prisma.user,
prisma,
mapper,
eventEmitter,
new LoggerBase({
logger: new Logger(UserRepository.name),
domain: 'user',
messagePublisher,
}),
);
}
}

View File

@ -0,0 +1,6 @@
import { PaginatedResponseDto } from '@mobicoop/ddd-library';
import { UserResponseDto } from './user.response.dto';
export class UserPaginatedResponseDto extends PaginatedResponseDto<UserResponseDto> {
readonly data: readonly UserResponseDto[];
}

View File

@ -0,0 +1,8 @@
import { ResponseBase } from '@mobicoop/ddd-library';
export class UserResponseDto extends ResponseBase {
firstName: string;
lastName: string;
email: string;
phone: string;
}

View File

@ -0,0 +1,49 @@
import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { AggregateID } from '@mobicoop/ddd-library';
import { IdResponse } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { RpcValidationPipe } from '@mobicoop/ddd-library';
import { CreateUserRequestDto } from './dtos/create-user.request.dto';
import { CreateUserCommand } from '@modules/user/core/application/commands/create-user/create-user.command';
import {
EmailAlreadyExistsException,
PhoneAlreadyExistsException,
UserAlreadyExistsException,
} from '@modules/user/core/domain/user.errors';
@UsePipes(
new RpcValidationPipe({
whitelist: false,
forbidUnknownValues: false,
}),
)
@Controller()
export class CreateUserGrpcController {
constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod('UserService', 'Create')
async create(data: CreateUserRequestDto): Promise<IdResponse> {
try {
const aggregateID: AggregateID = await this.commandBus.execute(
new CreateUserCommand(data),
);
return new IdResponse(aggregateID);
} catch (error: any) {
if (
error instanceof UserAlreadyExistsException ||
error instanceof EmailAlreadyExistsException ||
error instanceof PhoneAlreadyExistsException
)
throw new RpcException({
code: RpcExceptionCode.ALREADY_EXISTS,
message: error.message,
});
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: error.message,
});
}
}
}

View File

@ -0,0 +1,44 @@
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 { DeleteUserRequestDto } from './dtos/delete-user.request.dto';
import { DeleteUserCommand } from '@modules/user/core/application/commands/delete-user/delete-user.command';
@UsePipes(
new RpcValidationPipe({
whitelist: true,
forbidUnknownValues: false,
}),
)
@Controller()
export class DeleteUserGrpcController {
constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod('UserService', 'Delete')
async delete(data: DeleteUserRequestDto): Promise<void> {
try {
await this.commandBus.execute(new DeleteUserCommand(data));
} catch (error: any) {
if (error instanceof NotFoundException)
throw new RpcException({
code: RpcExceptionCode.NOT_FOUND,
message: error.message,
});
if (error instanceof DatabaseErrorException)
throw new RpcException({
code: RpcExceptionCode.INTERNAL,
message: error.message,
});
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: error.message,
});
}
}
}

View File

@ -0,0 +1,19 @@
import { IsEmail, IsOptional, IsPhoneNumber, IsString } from 'class-validator';
export class CreateUserRequestDto {
@IsString()
@IsOptional()
firstName: string;
@IsString()
@IsOptional()
lastName: string;
@IsEmail()
@IsOptional()
email?: string;
@IsPhoneNumber()
@IsOptional()
phone?: string;
}

View File

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

View File

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

View File

@ -1,29 +1,22 @@
import { AutoMap } from '@automapper/classes';
import { IsEmail, IsOptional, IsPhoneNumber, IsString } from 'class-validator';
export class CreateUserRequest {
export class UpdateUserRequestDto {
@IsString()
@IsOptional()
@AutoMap()
uuid?: string;
id: string;
@IsString()
@IsOptional()
@AutoMap()
firstName?: string;
@IsString()
@IsOptional()
@AutoMap()
lastName?: string;
@IsEmail()
@IsOptional()
@AutoMap()
email?: string;
@IsPhoneNumber()
@IsOptional()
@AutoMap()
phone?: string;
}

View File

@ -0,0 +1,46 @@
import { Controller, UsePipes } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { NotFoundException } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { RpcValidationPipe } from '@mobicoop/ddd-library';
import { UserMapper } from '@modules/user/user.mapper';
import { FindUserByIdRequestDto } from './dtos/find-user-by-id.request.dto';
import { UserResponseDto } from '../dtos/user.response.dto';
import { UserEntity } from '@modules/user/core/domain/user.entity';
import { FindUserByIdQuery } from '@modules/user/core/application/queries/find-user-by-id/find-user-by-id.query';
@UsePipes(
new RpcValidationPipe({
whitelist: false,
forbidUnknownValues: false,
}),
)
@Controller()
export class FindUserByIdGrpcController {
constructor(
protected readonly mapper: UserMapper,
private readonly queryBus: QueryBus,
) {}
@GrpcMethod('UserService', 'FindOneById')
async findOnebyId(data: FindUserByIdRequestDto): Promise<UserResponseDto> {
try {
const user: UserEntity = await this.queryBus.execute(
new FindUserByIdQuery(data.id),
);
return this.mapper.toResponse(user);
} catch (e) {
if (e instanceof NotFoundException) {
throw new RpcException({
code: RpcExceptionCode.NOT_FOUND,
message: e.message,
});
}
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: e.message,
});
}
}
}

View File

@ -0,0 +1,57 @@
import {
AggregateID,
IdResponse,
RpcExceptionCode,
RpcValidationPipe,
} from '@mobicoop/ddd-library';
import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { UpdateUserRequestDto } from './dtos/update-user.request.dto';
import { UpdateUserCommand } from '@modules/user/core/application/commands/update-user/update-user.command';
import {
EmailAlreadyExistsException,
PhoneAlreadyExistsException,
UserAlreadyExistsException,
} from '@modules/user/core/domain/user.errors';
@UsePipes(
new RpcValidationPipe({
whitelist: true,
forbidUnknownValues: false,
}),
)
@Controller()
export class UpdateUserGrpcController {
constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod('UserService', 'Update')
async updateUser(data: UpdateUserRequestDto): Promise<IdResponse> {
try {
const aggregateID: AggregateID = await this.commandBus.execute(
new UpdateUserCommand({
id: data.id,
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
phone: data.phone,
}),
);
return new IdResponse(aggregateID);
} catch (error: any) {
if (
error instanceof UserAlreadyExistsException ||
error instanceof EmailAlreadyExistsException ||
error instanceof PhoneAlreadyExistsException
)
throw new RpcException({
code: RpcExceptionCode.ALREADY_EXISTS,
message: error.message,
});
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: error.message,
});
}
}
}

View File

@ -2,20 +2,20 @@ syntax = "proto3";
package user;
service UsersService {
rpc FindOneByUuid(UserByUuid) returns (User);
service UserService {
rpc FindOneById(UserById) returns (User);
rpc FindAll(UserFilter) returns (Users);
rpc Create(User) returns (User);
rpc Update(User) returns (User);
rpc Delete(UserByUuid) returns (Empty);
rpc Create(User) returns (UserById);
rpc Update(User) returns (UserById);
rpc Delete(UserById) returns (Empty);
}
message UserByUuid {
string uuid = 1;
message UserById {
string id = 1;
}
message User {
string uuid = 1;
string id = 1;
string firstName = 2;
string lastName = 3;
string email = 4;

View File

@ -1,29 +0,0 @@
import { createMap, forMember, ignore, Mapper } from '@automapper/core';
import { AutomapperProfile, InjectMapper } from '@automapper/nestjs';
import { Injectable } from '@nestjs/common';
import { UserPresenter } from '../adapters/primaries/user.presenter';
import { CreateUserRequest } from '../domain/dtos/create-user.request';
import { UpdateUserRequest } from '../domain/dtos/update-user.request';
import { User } from '../domain/entities/user';
@Injectable()
export class UserProfile extends AutomapperProfile {
constructor(@InjectMapper() mapper: Mapper) {
super(mapper);
}
override get profile() {
return (mapper) => {
createMap(mapper, User, UserPresenter);
createMap(mapper, CreateUserRequest, User);
createMap(
mapper,
UpdateUserRequest,
User,
forMember((dest) => dest.uuid, ignore()),
);
};
}
}

View File

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

View File

@ -1,9 +0,0 @@
import { FindUserByUuidRequest } from '../domain/dtos/find-user-by-uuid.request';
export class FindUserByUuidQuery {
readonly uuid: string;
constructor(findUserByUuidRequest: FindUserByUuidRequest) {
this.uuid = findUserByUuidRequest.uuid;
}
}

View File

@ -0,0 +1,120 @@
import { UserEntity } from '@modules/user/core/domain/user.entity';
import { CreateUserProps } from '@modules/user/core/domain/user.types';
import { PrismaService } from '@modules/user/infrastructure/prisma.service';
import { UserRepository } from '@modules/user/infrastructure/user.repository';
import {
USER_MESSAGE_PUBLISHER,
USER_REPOSITORY,
} from '@modules/user/user.di-tokens';
import { UserMapper } from '@modules/user/user.mapper';
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { Test } from '@nestjs/testing';
describe('User Repository', () => {
let prismaService: PrismaService;
let userRepository: UserRepository;
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 createUsers = async (nbToCreate = 10) => {
for (let i = 0; i < nbToCreate; i++) {
const userToCreate = {
uuid: getSeed(i, baseUuid.uuid),
firstName: `John${i}`,
lastName: `Doe${i}`,
email: `john.doe${i}@email.com`,
phone: `+33611223344${i}`,
createdAt: '2023-07-24 13:07:05.000',
updatedAt: '2023-07-24 13:07:05.000',
};
userToCreate.uuid = getSeed(i, baseUuid.uuid);
await executeInsertCommand('user', userToCreate);
}
};
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,
UserMapper,
{
provide: USER_REPOSITORY,
useClass: UserRepository,
},
{
provide: USER_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
],
})
// disable logging
.setLogger(mockLogger)
.compile();
prismaService = module.get<PrismaService>(PrismaService);
userRepository = module.get<UserRepository>(USER_REPOSITORY);
});
afterAll(async () => {
await prismaService.$disconnect();
});
beforeEach(async () => {
await prismaService.user.deleteMany();
});
describe('findOneById', () => {
it('should return a user', async () => {
await createUsers(1);
const result = await userRepository.findOneById(baseUuid.uuid);
expect(result.id).toBe(baseUuid.uuid);
});
});
describe('create', () => {
it('should create a user', async () => {
const beforeCount = await prismaService.user.count();
const createUserProps: CreateUserProps = {
firstName: 'Jane',
lastName: 'Doe',
email: 'jane.doe@email.com',
phone: '+33622334455',
};
const userToCreate: UserEntity = UserEntity.create(createUserProps);
await userRepository.insert(userToCreate);
const afterCount = await prismaService.user.count();
expect(afterCount - beforeCount).toBe(1);
});
});
});

View File

@ -1,174 +0,0 @@
import { TestingModule, Test } from '@nestjs/testing';
import { DatabaseModule } from '../../../database/database.module';
import { PrismaService } from '../../../database/adapters/secondaries/prisma-service';
import { DatabaseException } from '../../../database/exceptions/database.exception';
import { UsersRepository } from '../../adapters/secondaries/users.repository';
import { User } from '../../domain/entities/user';
describe('UsersRepository', () => {
let prismaService: PrismaService;
let usersRepository: UsersRepository;
const createUsers = async (nbToCreate = 10) => {
for (let i = 0; i < nbToCreate; i++) {
await prismaService.user.create({
data: {
firstName: `firstName-${i}`,
},
});
}
};
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [DatabaseModule],
providers: [UsersRepository, PrismaService],
}).compile();
prismaService = module.get<PrismaService>(PrismaService);
usersRepository = module.get<UsersRepository>(UsersRepository);
});
afterAll(async () => {
await prismaService.$disconnect();
});
beforeEach(async () => {
await prismaService.user.deleteMany();
});
describe('findAll', () => {
it('should return an empty data array', async () => {
const res = await usersRepository.findAll();
expect(res).toEqual({
data: [],
total: 0,
});
});
it('should return a data array with 8 users', async () => {
await createUsers(8);
const users = await usersRepository.findAll();
expect(users.data.length).toBe(8);
expect(users.total).toBe(8);
});
it('should return a data array limited to 10 users', async () => {
await createUsers(20);
const users = await usersRepository.findAll();
expect(users.data.length).toBe(10);
expect(users.total).toBe(20);
});
});
describe('findOneByUuid', () => {
it('should return a user', async () => {
const userToFind = await prismaService.user.create({
data: {
firstName: 'test',
},
});
const user = await usersRepository.findOneByUuid(userToFind.uuid);
expect(user.uuid).toBe(userToFind.uuid);
});
it('should return null', async () => {
const user = await usersRepository.findOneByUuid(
'544572be-11fb-4244-8235-587221fc9104',
);
expect(user).toBeNull();
});
});
describe('findOne', () => {
it('should return a user according to its email', async () => {
const userToFind = await prismaService.user.create({
data: {
email: 'test@test.com',
},
});
const user = await usersRepository.findOne({
email: 'test@test.com',
});
expect(user.uuid).toBe(userToFind.uuid);
});
it('should return null with unknown email', async () => {
const user = await usersRepository.findOne({
email: 'wrong@email.com',
});
expect(user).toBeNull();
});
});
describe('create', () => {
it('should create a user', async () => {
const beforeCount = await prismaService.user.count();
const userToCreate: User = new User();
userToCreate.firstName = 'test';
const user = await usersRepository.create(userToCreate);
const afterCount = await prismaService.user.count();
expect(afterCount - beforeCount).toBe(1);
expect(user.uuid).toBeDefined();
});
});
describe('update', () => {
it('should update user firstName', async () => {
const userToUpdate = await prismaService.user.create({
data: {
firstName: 'test',
},
});
const toUpdate: User = new User();
toUpdate.firstName = 'updated';
const updateduser = await usersRepository.update(
userToUpdate.uuid,
toUpdate,
);
expect(updateduser.uuid).toBe(userToUpdate.uuid);
expect(updateduser.firstName).toBe('updated');
});
it('should throw DatabaseException', async () => {
const toUpdate: User = new User();
toUpdate.firstName = 'updated';
await expect(
usersRepository.update(
'544572be-11fb-4244-8235-587221fc9104',
toUpdate,
),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('delete', () => {
it('should delete a user', async () => {
const userToRemove = await prismaService.user.create({
data: {
firstName: 'test',
},
});
await usersRepository.delete(userToRemove.uuid);
const count = await prismaService.user.count();
expect(count).toBe(0);
});
it('should throw DatabaseException', async () => {
await expect(
usersRepository.delete('544572be-11fb-4244-8235-587221fc9104'),
).rejects.toBeInstanceOf(DatabaseException);
});
});
});

View File

@ -0,0 +1,105 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AggregateID, UniqueConstraintException } from '@mobicoop/ddd-library';
import { ConflictException } from '@mobicoop/ddd-library';
import { CreateUserRequestDto } from '@modules/user/interface/grpc-controllers/dtos/create-user.request.dto';
import { CreateUserService } from '@modules/user/core/application/commands/create-user/create-user.service';
import { USER_REPOSITORY } from '@modules/user/user.di-tokens';
import { UserEntity } from '@modules/user/core/domain/user.entity';
import { CreateUserCommand } from '@modules/user/core/application/commands/create-user/create-user.command';
import {
EmailAlreadyExistsException,
PhoneAlreadyExistsException,
UserAlreadyExistsException,
} from '@modules/user/core/domain/user.errors';
const createUserRequest: CreateUserRequestDto = {
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '+33611223344',
};
const mockUserRepository = {
insert: jest
.fn()
.mockImplementationOnce(() => ({}))
.mockImplementationOnce(() => {
throw new Error();
})
.mockImplementationOnce(() => {
throw new ConflictException('User already exists');
})
.mockImplementationOnce(() => {
throw new UniqueConstraintException('email already exists');
})
.mockImplementationOnce(() => {
throw new UniqueConstraintException('phone already exists');
}),
};
describe('create-user.service', () => {
let createUserService: CreateUserService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: USER_REPOSITORY,
useValue: mockUserRepository,
},
CreateUserService,
],
}).compile();
createUserService = module.get<CreateUserService>(CreateUserService);
});
it('should be defined', () => {
expect(createUserService).toBeDefined();
});
describe('execution', () => {
const createUserCommand = new CreateUserCommand(createUserRequest);
it('should create a new user', async () => {
UserEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
const result: AggregateID = await createUserService.execute(
createUserCommand,
);
expect(result).toBe('047a6ecf-23d4-4d3e-877c-3225d560a8da');
});
it('should throw an error if something bad happens', async () => {
UserEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
await expect(
createUserService.execute(createUserCommand),
).rejects.toBeInstanceOf(Error);
});
it('should throw an exception if User already exists', async () => {
UserEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
await expect(
createUserService.execute(createUserCommand),
).rejects.toBeInstanceOf(UserAlreadyExistsException);
});
it('should throw an exception if Email already exists', async () => {
UserEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
await expect(
createUserService.execute(createUserCommand),
).rejects.toBeInstanceOf(EmailAlreadyExistsException);
});
it('should throw an exception if Phone already exists', async () => {
UserEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
await expect(
createUserService.execute(createUserCommand),
).rejects.toBeInstanceOf(PhoneAlreadyExistsException);
});
});
});

View File

@ -0,0 +1,50 @@
import { DeleteUserCommand } from '@modules/user/core/application/commands/delete-user/delete-user.command';
import { DeleteUserService } from '@modules/user/core/application/commands/delete-user/delete-user.service';
import { DeleteUserRequestDto } from '@modules/user/interface/grpc-controllers/dtos/delete-user.request.dto';
import { USER_REPOSITORY } from '@modules/user/user.di-tokens';
import { Test, TestingModule } from '@nestjs/testing';
const deleteUserRequest: DeleteUserRequestDto = {
id: '165192d4-398a-4469-a16b-98c02cc6f531',
};
const mockUserEntity = {
delete: jest.fn(),
};
const mockUserRepository = {
findOneById: jest.fn().mockImplementation(() => mockUserEntity),
delete: jest.fn().mockImplementationOnce(() => true),
};
describe('Delete User Service', () => {
let deleteUserService: DeleteUserService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: USER_REPOSITORY,
useValue: mockUserRepository,
},
DeleteUserService,
],
}).compile();
deleteUserService = module.get<DeleteUserService>(DeleteUserService);
});
it('should be defined', () => {
expect(deleteUserService).toBeDefined();
});
describe('execution', () => {
const deleteUserCommand = new DeleteUserCommand(deleteUserRequest);
it('should delete a user', async () => {
const result: boolean = await deleteUserService.execute(
deleteUserCommand,
);
expect(result).toBeTruthy();
});
});
});

View File

@ -0,0 +1,58 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UserEntity } from '@modules/user/core/domain/user.entity';
import { FindUserByIdQueryHandler } from '@modules/user/core/application/queries/find-user-by-id/find-user-by-id.query-handler';
import { USER_REPOSITORY } from '@modules/user/user.di-tokens';
import { FindUserByIdQuery } from '@modules/user/core/application/queries/find-user-by-id/find-user-by-id.query';
const now = new Date('2023-06-21 06:00:00');
const user: UserEntity = new UserEntity({
id: 'c160cf8c-f057-4962-841f-3ad68346df44',
props: {
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '+33611223344',
},
createdAt: now,
updatedAt: now,
});
const mockUserRepository = {
findOneById: jest.fn().mockImplementation(() => user),
};
describe('find-user-by-id.query-handler', () => {
let findUserByIdQueryHandler: FindUserByIdQueryHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: USER_REPOSITORY,
useValue: mockUserRepository,
},
FindUserByIdQueryHandler,
],
}).compile();
findUserByIdQueryHandler = module.get<FindUserByIdQueryHandler>(
FindUserByIdQueryHandler,
);
});
it('should be defined', () => {
expect(findUserByIdQueryHandler).toBeDefined();
});
describe('execution', () => {
it('should return a user', async () => {
const findUserbyIdQuery = new FindUserByIdQuery(
'dd264806-13b4-4226-9b18-87adf0ad5dd1',
);
const user: UserEntity = await findUserByIdQueryHandler.execute(
findUserbyIdQuery,
);
expect(user.getProps().lastName).toBe('Doe');
});
});
});

View File

@ -0,0 +1,54 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PublishMessageWhenUserIsCreatedDomainEventHandler } from '@modules/user/core/application/event-handlers/publish-message-when-user-is-created.domain-event-handler';
import { USER_MESSAGE_PUBLISHER } from '@modules/user/user.di-tokens';
import { UserCreatedDomainEvent } from '@modules/user/core/domain/events/user-created.domain-event';
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('Publish message when user is created domain event handler', () => {
let publishMessageWhenUserIsCreatedDomainEventHandler: PublishMessageWhenUserIsCreatedDomainEventHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: USER_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
PublishMessageWhenUserIsCreatedDomainEventHandler,
],
}).compile();
publishMessageWhenUserIsCreatedDomainEventHandler =
module.get<PublishMessageWhenUserIsCreatedDomainEventHandler>(
PublishMessageWhenUserIsCreatedDomainEventHandler,
);
});
it('should publish a message', () => {
jest.spyOn(mockMessagePublisher, 'publish');
const userCreatedDomainEvent: UserCreatedDomainEvent = {
id: 'some-domain-event-id',
aggregateId: 'some-aggregate-id',
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '+33611223344',
metadata: {
timestamp: new Date('2023-06-28T05:00:00Z').getTime(),
correlationId: 'some-correlation-id',
},
};
publishMessageWhenUserIsCreatedDomainEventHandler.handle(
userCreatedDomainEvent,
);
expect(publishMessageWhenUserIsCreatedDomainEventHandler).toBeDefined();
expect(mockMessagePublisher.publish).toHaveBeenCalledTimes(1);
expect(mockMessagePublisher.publish).toHaveBeenCalledWith(
'user.created',
'{"id":"some-aggregate-id","metadata":{"correlationId":"some-correlation-id","timestamp":1687928400000},"firstName":"John","lastName":"Doe","email":"john.doe@email.com","phone":"+33611223344"}',
);
});
});

View File

@ -0,0 +1,54 @@
import { Test, TestingModule } from '@nestjs/testing';
import { USER_MESSAGE_PUBLISHER } from '@modules/user/user.di-tokens';
import { UserCreatedDomainEvent } from '@modules/user/core/domain/events/user-created.domain-event';
import { PublishMessageWhenUserIsDeletedDomainEventHandler } from '@modules/user/core/application/event-handlers/publish-message-when-user-is-deleted.domain-event-handler';
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('Publish message when user is deleted domain event handler', () => {
let publishMessageWhenUserIsDeletedDomainEventHandler: PublishMessageWhenUserIsDeletedDomainEventHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: USER_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
PublishMessageWhenUserIsDeletedDomainEventHandler,
],
}).compile();
publishMessageWhenUserIsDeletedDomainEventHandler =
module.get<PublishMessageWhenUserIsDeletedDomainEventHandler>(
PublishMessageWhenUserIsDeletedDomainEventHandler,
);
});
it('should publish a message', () => {
jest.spyOn(mockMessagePublisher, 'publish');
const userDeletedDomainEvent: UserCreatedDomainEvent = {
id: 'some-domain-event-id',
aggregateId: 'some-aggregate-id',
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '+33611223344',
metadata: {
timestamp: new Date('2023-06-28T05:00:00Z').getTime(),
correlationId: 'some-correlation-id',
},
};
publishMessageWhenUserIsDeletedDomainEventHandler.handle(
userDeletedDomainEvent,
);
expect(publishMessageWhenUserIsDeletedDomainEventHandler).toBeDefined();
expect(mockMessagePublisher.publish).toHaveBeenCalledTimes(1);
expect(mockMessagePublisher.publish).toHaveBeenCalledWith(
'user.deleted',
'{"id":"some-aggregate-id","metadata":{"correlationId":"some-correlation-id","timestamp":1687928400000}}',
);
});
});

View File

@ -0,0 +1,54 @@
import { Test, TestingModule } from '@nestjs/testing';
import { USER_MESSAGE_PUBLISHER } from '@modules/user/user.di-tokens';
import { PublishMessageWhenUserIsUpdatedDomainEventHandler } from '@modules/user/core/application/event-handlers/publish-message-when-user-is-updated.domain-event-handler';
import { UserUpdatedDomainEvent } from '@modules/user/core/domain/events/user-updated.domain-event';
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('Publish message when user is updated domain event handler', () => {
let publishMessageWhenUserIsUpdatedDomainEventHandler: PublishMessageWhenUserIsUpdatedDomainEventHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: USER_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
PublishMessageWhenUserIsUpdatedDomainEventHandler,
],
}).compile();
publishMessageWhenUserIsUpdatedDomainEventHandler =
module.get<PublishMessageWhenUserIsUpdatedDomainEventHandler>(
PublishMessageWhenUserIsUpdatedDomainEventHandler,
);
});
it('should publish a message', () => {
jest.spyOn(mockMessagePublisher, 'publish');
const userUpdatedDomainEvent: UserUpdatedDomainEvent = {
id: 'some-domain-event-id',
aggregateId: 'some-aggregate-id',
firstName: 'Jane',
lastName: 'Doe',
email: 'jane.doe@email.com',
phone: '+33611223344',
metadata: {
timestamp: new Date('2023-06-28T05:00:00Z').getTime(),
correlationId: 'some-correlation-id',
},
};
publishMessageWhenUserIsUpdatedDomainEventHandler.handle(
userUpdatedDomainEvent,
);
expect(publishMessageWhenUserIsUpdatedDomainEventHandler).toBeDefined();
expect(mockMessagePublisher.publish).toHaveBeenCalledTimes(1);
expect(mockMessagePublisher.publish).toHaveBeenCalledWith(
'user.updated',
'{"id":"some-aggregate-id","metadata":{"correlationId":"some-correlation-id","timestamp":1687928400000},"firstName":"Jane","lastName":"Doe","email":"jane.doe@email.com","phone":"+33611223344"}',
);
});
});

View File

@ -0,0 +1,126 @@
import { Test, TestingModule } from '@nestjs/testing';
import {
AggregateID,
NotFoundException,
UniqueConstraintException,
} from '@mobicoop/ddd-library';
import { USER_REPOSITORY } from '@modules/user/user.di-tokens';
import { UserEntity } from '@modules/user/core/domain/user.entity';
import {
EmailAlreadyExistsException,
PhoneAlreadyExistsException,
} from '@modules/user/core/domain/user.errors';
import { UpdateUserRequestDto } from '@modules/user/interface/grpc-controllers/dtos/update-user.request.dto';
import { UpdateUserService } from '@modules/user/core/application/commands/update-user/update-user.service';
import { UpdateUserCommand } from '@modules/user/core/application/commands/update-user/update-user.command';
const updateFirstNameUserRequest: UpdateUserRequestDto = {
id: 'c97b1783-76cf-4840-b298-b90b13c58894',
firstName: 'Johnny',
};
const updateEmailUserRequest: UpdateUserRequestDto = {
id: 'c97b1783-76cf-4840-b298-b90b13c58894',
email: 'john.doe@already.exists.email.com',
};
const updatePhoneUserRequest: UpdateUserRequestDto = {
id: 'c97b1783-76cf-4840-b298-b90b13c58894',
phone: '+33611223344',
};
const now = new Date();
const userToUpdate: UserEntity = new UserEntity({
id: 'c97b1783-76cf-4840-b298-b90b13c58894',
createdAt: now,
updatedAt: now,
props: {
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '+33611223344',
},
});
const mockUserRepository = {
findOneById: jest
.fn()
.mockImplementationOnce(() => {
throw new NotFoundException('Record not found');
})
.mockImplementation(() => userToUpdate),
update: jest
.fn()
.mockImplementationOnce(() => 'c97b1783-76cf-4840-b298-b90b13c58894')
.mockImplementationOnce(() => {
throw new UniqueConstraintException('email already exists');
})
.mockImplementationOnce(() => {
throw new UniqueConstraintException('phone already exists');
})
.mockImplementationOnce(() => {
throw new Error();
}),
};
describe('update-user.service', () => {
let updateUserService: UpdateUserService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: USER_REPOSITORY,
useValue: mockUserRepository,
},
UpdateUserService,
],
}).compile();
updateUserService = module.get<UpdateUserService>(UpdateUserService);
});
it('should be defined', () => {
expect(updateUserService).toBeDefined();
});
describe('execution', () => {
it('should throw an exception if user is not found', async () => {
const updateUserCommand = new UpdateUserCommand(
updateFirstNameUserRequest,
);
await expect(
updateUserService.execute(updateUserCommand),
).rejects.toBeInstanceOf(NotFoundException);
});
it('should update a user firstName', async () => {
jest.spyOn(userToUpdate, 'update');
const updateUserCommand = new UpdateUserCommand(
updateFirstNameUserRequest,
);
const result: AggregateID = await updateUserService.execute(
updateUserCommand,
);
expect(result).toBe('c97b1783-76cf-4840-b298-b90b13c58894');
expect(userToUpdate.update).toHaveBeenCalledTimes(1);
});
it('should throw an exception if Email already exists', async () => {
const updateUserCommand = new UpdateUserCommand(updateEmailUserRequest);
await expect(
updateUserService.execute(updateUserCommand),
).rejects.toBeInstanceOf(EmailAlreadyExistsException);
});
it('should throw an exception if Phone already exists', async () => {
const updateUserCommand = new UpdateUserCommand(updatePhoneUserRequest);
await expect(
updateUserService.execute(updateUserCommand),
).rejects.toBeInstanceOf(PhoneAlreadyExistsException);
});
it('should throw an error if something bad happens', async () => {
const updateUserCommand = new UpdateUserCommand(updatePhoneUserRequest);
await expect(
updateUserService.execute(updateUserCommand),
).rejects.toBeInstanceOf(Error);
});
});
});

View File

@ -0,0 +1,54 @@
import { UserCreatedDomainEvent } from '@modules/user/core/domain/events/user-created.domain-event';
import { UserDeletedDomainEvent } from '@modules/user/core/domain/events/user-deleted.domain-event';
import { UserUpdatedDomainEvent } from '@modules/user/core/domain/events/user-updated.domain-event';
import { UserEntity } from '@modules/user/core/domain/user.entity';
import {
CreateUserProps,
UpdateUserProps,
} from '@modules/user/core/domain/user.types';
const createUserProps: CreateUserProps = {
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '+33611223344',
};
const updateUserProps: UpdateUserProps = {
firstName: 'Jane',
lastName: 'Dane',
email: 'jane.dane@email.com',
};
describe('User entity create', () => {
it('should create a new user entity', async () => {
const userEntity: UserEntity = UserEntity.create(createUserProps);
expect(userEntity.id.length).toBe(36);
expect(userEntity.getProps().email).toBe('john.doe@email.com');
expect(userEntity.domainEvents.length).toBe(1);
expect(userEntity.domainEvents[0]).toBeInstanceOf(UserCreatedDomainEvent);
});
});
describe('User entity update', () => {
it('should update a user entity', async () => {
const userEntity: UserEntity = UserEntity.create(createUserProps);
userEntity.update(updateUserProps);
expect(userEntity.getProps().firstName).toBe('Jane');
expect(userEntity.getProps().lastName).toBe('Dane');
expect(userEntity.getProps().email).toBe('jane.dane@email.com');
// 2 events because UserEntity.create sends a UserCreatedDomainEvent
expect(userEntity.domainEvents.length).toBe(2);
expect(userEntity.domainEvents[1]).toBeInstanceOf(UserUpdatedDomainEvent);
});
});
describe('User entity delete', () => {
it('should delete a user entity', async () => {
const userEntity: UserEntity = UserEntity.create(createUserProps);
userEntity.delete();
// 2 events because UserEntity.create sends a UserCreatedDomainEvent
expect(userEntity.domainEvents.length).toBe(2);
expect(userEntity.domainEvents[1]).toBeInstanceOf(UserDeletedDomainEvent);
});
});

View File

@ -1,79 +0,0 @@
import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs';
import { Test, TestingModule } from '@nestjs/testing';
import { UsersRepository } from '../../adapters/secondaries/users.repository';
import { CreateUserCommand } from '../../commands/create-user.command';
import { CreateUserRequest } from '../../domain/dtos/create-user.request';
import { User } from '../../domain/entities/user';
import { CreateUserUseCase } from '../../domain/usecases/create-user.usecase';
import { UserProfile } from '../../mappers/user.profile';
import { MESSAGE_PUBLISHER } from '../../../../app.constants';
const newUserRequest: CreateUserRequest = {
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '0601020304',
};
const newUserCommand: CreateUserCommand = new CreateUserCommand(newUserRequest);
const mockUsersRepository = {
create: jest
.fn()
.mockImplementationOnce(() => {
return Promise.resolve({
...newUserRequest,
uuid: 'bb281075-1b98-4456-89d6-c643d3044a91',
});
})
.mockImplementation(() => {
throw new Error('Already exists');
}),
};
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('CreateUserUseCase', () => {
let createUserUseCase: CreateUserUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })],
providers: [
{
provide: UsersRepository,
useValue: mockUsersRepository,
},
CreateUserUseCase,
UserProfile,
{
provide: MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
],
}).compile();
createUserUseCase = module.get<CreateUserUseCase>(CreateUserUseCase);
});
it('should be defined', () => {
expect(createUserUseCase).toBeDefined();
});
describe('execute', () => {
it('should create and return a new user', async () => {
const newUser: User = await createUserUseCase.execute(newUserCommand);
expect(newUser.lastName).toBe(newUserRequest.lastName);
expect(newUser.uuid).toBeDefined();
});
it('should throw an error if user already exists', async () => {
await expect(
createUserUseCase.execute(newUserCommand),
).rejects.toBeInstanceOf(Error);
});
});
});

View File

@ -1,93 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UsersRepository } from '../../adapters/secondaries/users.repository';
import { DeleteUserCommand } from '../../commands/delete-user.command';
import { DeleteUserUseCase } from '../../domain/usecases/delete-user.usecase';
import { MESSAGE_PUBLISHER } from '../../../../app.constants';
const mockUsers = [
{
uuid: 'bb281075-1b98-4456-89d6-c643d3044a91',
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '0601020304',
},
{
uuid: 'bb281075-1b98-4456-89d6-c643d3044a92',
firstName: 'Jane',
lastName: 'Doe',
email: 'jane.doe@email.com',
phone: '0602030405',
},
{
uuid: 'bb281075-1b98-4456-89d6-c643d3044a93',
firstName: 'Jimmy',
lastName: 'Doe',
email: 'jimmy.doe@email.com',
phone: '0603040506',
},
];
const mockUsersRepository = {
delete: jest
.fn()
.mockImplementationOnce((uuid: string) => {
let savedUser = {};
mockUsers.forEach((user, index) => {
if (user.uuid === uuid) {
savedUser = { ...user };
mockUsers.splice(index, 1);
}
});
return savedUser;
})
.mockImplementation(() => {
throw new Error('Error');
}),
};
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('DeleteUserUseCase', () => {
let deleteUserUseCase: DeleteUserUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: UsersRepository,
useValue: mockUsersRepository,
},
DeleteUserUseCase,
{
provide: MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
],
}).compile();
deleteUserUseCase = module.get<DeleteUserUseCase>(DeleteUserUseCase);
});
it('should be defined', () => {
expect(deleteUserUseCase).toBeDefined();
});
describe('execute', () => {
it('should delete a user', async () => {
const savedUuid = mockUsers[0].uuid;
const deleteUserCommand = new DeleteUserCommand(savedUuid);
await deleteUserUseCase.execute(deleteUserCommand);
const deletedUser = mockUsers.find((user) => user.uuid === savedUuid);
expect(deletedUser).toBeUndefined();
});
it('should throw an error if user does not exist', async () => {
await expect(
deleteUserUseCase.execute(new DeleteUserCommand('wrong uuid')),
).rejects.toBeInstanceOf(Error);
});
});
});

View File

@ -1,74 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UsersRepository } from '../../adapters/secondaries/users.repository';
import { FindAllUsersRequest } from '../../domain/dtos/find-all-users.request';
import { FindAllUsersUseCase } from '../../domain/usecases/find-all-users.usecase';
import { FindAllUsersQuery } from '../../queries/find-all-users.query';
const findAllUsersRequest: FindAllUsersRequest = new FindAllUsersRequest();
findAllUsersRequest.page = 1;
findAllUsersRequest.perPage = 10;
const findAllUsersQuery: FindAllUsersQuery = new FindAllUsersQuery(
findAllUsersRequest,
);
const mockUsers = [
{
uuid: 'bb281075-1b98-4456-89d6-c643d3044a91',
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '0601020304',
},
{
uuid: 'bb281075-1b98-4456-89d6-c643d3044a92',
firstName: 'Jane',
lastName: 'Doe',
email: 'jane.doe@email.com',
phone: '0602030405',
},
{
uuid: 'bb281075-1b98-4456-89d6-c643d3044a93',
firstName: 'Jimmy',
lastName: 'Doe',
email: 'jimmy.doe@email.com',
phone: '0603040506',
},
];
const mockUsersRepository = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
findAll: jest.fn().mockImplementation((query?: FindAllUsersQuery) => {
return Promise.resolve(mockUsers);
}),
};
describe('FindAllUsersUseCase', () => {
let findAllUsersUseCase: FindAllUsersUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: UsersRepository,
useValue: mockUsersRepository,
},
FindAllUsersUseCase,
],
}).compile();
findAllUsersUseCase = module.get<FindAllUsersUseCase>(FindAllUsersUseCase);
});
it('should be defined', () => {
expect(findAllUsersUseCase).toBeDefined();
});
describe('execute', () => {
it('should return an array filled with users', async () => {
const users = await findAllUsersUseCase.execute(findAllUsersQuery);
expect(users).toBe(mockUsers);
});
});
});

View File

@ -1,82 +0,0 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { UsersRepository } from '../../adapters/secondaries/users.repository';
import { FindUserByUuidRequest } from '../../domain/dtos/find-user-by-uuid.request';
import { FindUserByUuidUseCase } from '../../domain/usecases/find-user-by-uuid.usecase';
import { FindUserByUuidQuery } from '../../queries/find-user-by-uuid.query';
import { MESSAGE_PUBLISHER } from '../../../../app.constants';
const mockUser = {
uuid: 'bb281075-1b98-4456-89d6-c643d3044a91',
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '0601020304',
};
const mockUserRepository = {
findOneByUuid: jest
.fn()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((query?: FindUserByUuidQuery) => {
return Promise.resolve(mockUser);
})
.mockImplementation(() => {
return Promise.resolve(undefined);
}),
};
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('FindUserByUuidUseCase', () => {
let findUserByUuidUseCase: FindUserByUuidUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
{
provide: UsersRepository,
useValue: mockUserRepository,
},
{
provide: MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
FindUserByUuidUseCase,
],
}).compile();
findUserByUuidUseCase = module.get<FindUserByUuidUseCase>(
FindUserByUuidUseCase,
);
});
it('should be defined', () => {
expect(findUserByUuidUseCase).toBeDefined();
});
describe('execute', () => {
it('should return a user', async () => {
const findUserByUuidRequest: FindUserByUuidRequest =
new FindUserByUuidRequest();
findUserByUuidRequest.uuid = 'bb281075-1b98-4456-89d6-c643d3044a91';
const user = await findUserByUuidUseCase.execute(
new FindUserByUuidQuery(findUserByUuidRequest),
);
expect(user).toBe(mockUser);
});
it('should throw an error if user does not exist', async () => {
const findUserByUuidRequest: FindUserByUuidRequest =
new FindUserByUuidRequest();
findUserByUuidRequest.uuid = 'bb281075-1b98-4456-89d6-c643d3044a90';
await expect(
findUserByUuidUseCase.execute(
new FindUserByUuidQuery(findUserByUuidRequest),
),
).rejects.toBeInstanceOf(NotFoundException);
});
});
});

View File

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

View File

@ -0,0 +1,123 @@
import { IdResponse } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
import {
EmailAlreadyExistsException,
PhoneAlreadyExistsException,
UserAlreadyExistsException,
} from '@modules/user/core/domain/user.errors';
import { CreateUserGrpcController } from '@modules/user/interface/grpc-controllers/create-user.grpc.controller';
import { CreateUserRequestDto } from '@modules/user/interface/grpc-controllers/dtos/create-user.request.dto';
const createUserRequest: CreateUserRequestDto = {
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '+33611223344',
};
const mockCommandBus = {
execute: jest
.fn()
.mockImplementationOnce(() => '200d61a8-d878-4378-a609-c19ea71633d2')
.mockImplementationOnce(() => {
throw new UserAlreadyExistsException();
})
.mockImplementationOnce(() => {
throw new EmailAlreadyExistsException();
})
.mockImplementationOnce(() => {
throw new PhoneAlreadyExistsException();
})
.mockImplementationOnce(() => {
throw new Error();
}),
};
describe('Create User Grpc Controller', () => {
let createUserGrpcController: CreateUserGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
CreateUserGrpcController,
],
}).compile();
createUserGrpcController = module.get<CreateUserGrpcController>(
CreateUserGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(createUserGrpcController).toBeDefined();
});
it('should create a new user', async () => {
jest.spyOn(mockCommandBus, 'execute');
const result: IdResponse = await createUserGrpcController.create(
createUserRequest,
);
expect(result).toBeInstanceOf(IdResponse);
expect(result.id).toBe('200d61a8-d878-4378-a609-c19ea71633d2');
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if user already exists', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await createUserGrpcController.create(createUserRequest);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.ALREADY_EXISTS);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if email already exists', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await createUserGrpcController.create(createUserRequest);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.ALREADY_EXISTS);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if phone already exists', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await createUserGrpcController.create(createUserRequest);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.ALREADY_EXISTS);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a generic RpcException', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await createUserGrpcController.create(createUserRequest);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,99 @@
import {
DatabaseErrorException,
NotFoundException,
} from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { DeleteUserGrpcController } from '@modules/user/interface/grpc-controllers/delete-user.grpc.controller';
import { DeleteUserRequestDto } from '@modules/user/interface/grpc-controllers/dtos/delete-user.request.dto';
import { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
const deleteUserRequest: DeleteUserRequestDto = {
id: '78153e03-4861-4f58-a705-88526efee53b',
};
const mockCommandBus = {
execute: jest
.fn()
.mockImplementationOnce(() => ({}))
.mockImplementationOnce(() => {
throw new NotFoundException();
})
.mockImplementationOnce(() => {
throw new DatabaseErrorException();
})
.mockImplementationOnce(() => {
throw new Error();
}),
};
describe('Delete User Grpc Controller', () => {
let deleteUserGrpcController: DeleteUserGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
DeleteUserGrpcController,
],
}).compile();
deleteUserGrpcController = module.get<DeleteUserGrpcController>(
DeleteUserGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(deleteUserGrpcController).toBeDefined();
});
it('should delete a user', async () => {
jest.spyOn(mockCommandBus, 'execute');
await deleteUserGrpcController.delete(deleteUserRequest);
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if user does not exist', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await deleteUserGrpcController.delete(deleteUserRequest);
} 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 deleteUserGrpcController.delete(deleteUserRequest);
} 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 deleteUserGrpcController.delete(deleteUserRequest);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,103 @@
import { NotFoundException } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { FindUserByIdGrpcController } from '@modules/user/interface/grpc-controllers/find-user-by-id.grpc.controller';
import { UserMapper } from '@modules/user/user.mapper';
import { QueryBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
const mockQueryBus = {
execute: jest
.fn()
.mockImplementationOnce(() => '200d61a8-d878-4378-a609-c19ea71633d2')
.mockImplementationOnce(() => {
throw new NotFoundException();
})
.mockImplementationOnce(() => {
throw new Error();
}),
};
const mockUserMapper = {
toResponse: jest.fn().mockImplementationOnce(() => ({
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '+33611223344',
})),
};
describe('Find User By Id Grpc Controller', () => {
let findUserbyIdGrpcController: FindUserByIdGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: QueryBus,
useValue: mockQueryBus,
},
{
provide: UserMapper,
useValue: mockUserMapper,
},
FindUserByIdGrpcController,
],
}).compile();
findUserbyIdGrpcController = module.get<FindUserByIdGrpcController>(
FindUserByIdGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(findUserbyIdGrpcController).toBeDefined();
});
it('should return a user', async () => {
jest.spyOn(mockQueryBus, 'execute');
jest.spyOn(mockUserMapper, 'toResponse');
const response = await findUserbyIdGrpcController.findOnebyId({
id: '6dcf093c-c7db-4dae-8e9c-c715cebf83c7',
});
expect(response.firstName).toBe('John');
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
expect(mockUserMapper.toResponse).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if user is not found', async () => {
jest.spyOn(mockQueryBus, 'execute');
jest.spyOn(mockUserMapper, 'toResponse');
expect.assertions(4);
try {
await findUserbyIdGrpcController.findOnebyId({
id: 'ac85f5f4-41cd-4c5d-9aee-0a1acb176fb8',
});
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.NOT_FOUND);
}
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
expect(mockUserMapper.toResponse).toHaveBeenCalledTimes(0);
});
it('should throw a generic RpcException', async () => {
jest.spyOn(mockQueryBus, 'execute');
jest.spyOn(mockUserMapper, 'toResponse');
expect.assertions(4);
try {
await findUserbyIdGrpcController.findOnebyId({
id: '53c8e7ec-ef68-42bc-ba4c-5ef3effa60a6',
});
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
expect(mockUserMapper.toResponse).toHaveBeenCalledTimes(0);
});
});

View File

@ -0,0 +1,116 @@
import { IdResponse } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import {
EmailAlreadyExistsException,
PhoneAlreadyExistsException,
} from '@modules/user/core/domain/user.errors';
import { UpdateUserRequestDto } from '@modules/user/interface/grpc-controllers/dtos/update-user.request.dto';
import { UpdateUserGrpcController } from '@modules/user/interface/grpc-controllers/update-user.grpc.controller';
import { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
const updateFirstNameUserRequest: UpdateUserRequestDto = {
id: 'c97b1783-76cf-4840-b298-b90b13c58894',
firstName: 'Johnny',
};
const updateEmailUserRequest: UpdateUserRequestDto = {
id: 'c97b1783-76cf-4840-b298-b90b13c58894',
email: 'john.doe@already.exists.email.com',
};
const updatePhoneUserRequest: UpdateUserRequestDto = {
id: 'c97b1783-76cf-4840-b298-b90b13c58894',
phone: '+33611223344',
};
const mockCommandBus = {
execute: jest
.fn()
.mockImplementationOnce(() => 'c97b1783-76cf-4840-b298-b90b13c58894')
.mockImplementationOnce(() => {
throw new EmailAlreadyExistsException();
})
.mockImplementationOnce(() => {
throw new PhoneAlreadyExistsException();
})
.mockImplementationOnce(() => {
throw new Error();
}),
};
describe('Update User Grpc Controller', () => {
let updateUserGrpcController: UpdateUserGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
UpdateUserGrpcController,
],
}).compile();
updateUserGrpcController = module.get<UpdateUserGrpcController>(
UpdateUserGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(updateUserGrpcController).toBeDefined();
});
it('should update a user', async () => {
jest.spyOn(mockCommandBus, 'execute');
const result: IdResponse = await updateUserGrpcController.updateUser(
updateFirstNameUserRequest,
);
expect(result).toBeInstanceOf(IdResponse);
expect(result.id).toBe('c97b1783-76cf-4840-b298-b90b13c58894');
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if email already exists', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await updateUserGrpcController.updateUser(updateEmailUserRequest);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.ALREADY_EXISTS);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if phone already exists', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await updateUserGrpcController.updateUser(updatePhoneUserRequest);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.ALREADY_EXISTS);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a generic RpcException', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await updateUserGrpcController.updateUser(updateFirstNameUserRequest);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,36 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MessagePublisher } from '../../adapters/secondaries/message-publisher';
import { MESSAGE_BROKER_PUBLISHER } from '../../../../app.constants';
const mockMessageBrokerPublisher = {
publish: jest.fn().mockImplementation(),
};
describe('Messager', () => {
let messagePublisher: MessagePublisher;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
MessagePublisher,
{
provide: MESSAGE_BROKER_PUBLISHER,
useValue: mockMessageBrokerPublisher,
},
],
}).compile();
messagePublisher = module.get<MessagePublisher>(MessagePublisher);
});
it('should be defined', () => {
expect(messagePublisher).toBeDefined();
});
it('should publish a message', async () => {
jest.spyOn(mockMessageBrokerPublisher, 'publish');
messagePublisher.publish('user.create.info', 'my-test');
expect(mockMessageBrokerPublisher.publish).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,84 +0,0 @@
import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs';
import { Test, TestingModule } from '@nestjs/testing';
import { UsersRepository } from '../../adapters/secondaries/users.repository';
import { UpdateUserCommand } from '../../commands/update-user.command';
import { UpdateUserRequest } from '../../domain/dtos/update-user.request';
import { User } from '../../domain/entities/user';
import { UpdateUserUseCase } from '../../domain/usecases/update-user.usecase';
import { UserProfile } from '../../mappers/user.profile';
import { MESSAGE_PUBLISHER } from '../../../../app.constants';
const originalUser: User = new User();
originalUser.uuid = 'bb281075-1b98-4456-89d6-c643d3044a91';
originalUser.lastName = 'Doe';
const updateUserRequest: UpdateUserRequest = {
uuid: 'bb281075-1b98-4456-89d6-c643d3044a91',
lastName: 'Dane',
};
const updateUserCommand: UpdateUserCommand = new UpdateUserCommand(
updateUserRequest,
);
const mockUsersRepository = {
update: jest
.fn()
.mockImplementationOnce((uuid: string, params: any) => {
originalUser.lastName = params.lastName;
return Promise.resolve(originalUser);
})
.mockImplementation(() => {
throw new Error('Error');
}),
};
const mockMessager = {
publish: jest.fn().mockImplementation(),
};
describe('UpdateUserUseCase', () => {
let updateUserUseCase: UpdateUserUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })],
providers: [
{
provide: UsersRepository,
useValue: mockUsersRepository,
},
UpdateUserUseCase,
UserProfile,
{
provide: MESSAGE_PUBLISHER,
useValue: mockMessager,
},
],
}).compile();
updateUserUseCase = module.get<UpdateUserUseCase>(UpdateUserUseCase);
});
it('should be defined', () => {
expect(updateUserUseCase).toBeDefined();
});
describe('execute', () => {
it('should update a user', async () => {
const updatedUser: User = await updateUserUseCase.execute(
updateUserCommand,
);
expect(updatedUser.lastName).toBe(updateUserRequest.lastName);
});
it('should throw an error if user does not exist', async () => {
await expect(
updateUserUseCase.execute(updateUserCommand),
).rejects.toBeInstanceOf(Error);
});
});
});

View File

@ -0,0 +1,60 @@
import { UserEntity } from '@modules/user/core/domain/user.entity';
import {
UserReadModel,
UserWriteModel,
} from '@modules/user/infrastructure/user.repository';
import { UserResponseDto } from '@modules/user/interface/dtos/user.response.dto';
import { UserMapper } from '@modules/user/user.mapper';
import { Test } from '@nestjs/testing';
const now = new Date('2023-06-21 06:00:00');
const userEntity: UserEntity = new UserEntity({
id: 'c160cf8c-f057-4962-841f-3ad68346df44',
props: {
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '+33611223344',
},
createdAt: now,
updatedAt: now,
});
const userReadModel: UserReadModel = {
uuid: 'c160cf8c-f057-4962-841f-3ad68346df44',
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@email.com',
phone: '+33611223344',
createdAt: now,
updatedAt: now,
};
describe('User Mapper', () => {
let userMapper: UserMapper;
beforeAll(async () => {
const module = await Test.createTestingModule({
providers: [UserMapper],
}).compile();
userMapper = module.get<UserMapper>(UserMapper);
});
it('should be defined', () => {
expect(userMapper).toBeDefined();
});
it('should map domain entity to persistence data', async () => {
const mapped: UserWriteModel = userMapper.toPersistence(userEntity);
expect(mapped.lastName).toBe('Doe');
});
it('should map persisted data to domain entity', async () => {
const mapped: UserEntity = userMapper.toDomain(userReadModel);
expect(mapped.getProps().firstName).toBe('John');
});
it('should map domain entity to response', async () => {
const mapped: UserResponseDto = userMapper.toResponse(userEntity);
expect(mapped.id).toBe('c160cf8c-f057-4962-841f-3ad68346df44');
});
});

View File

@ -0,0 +1,3 @@
export const USER_CREATED_ROUTING_KEY = 'user.created';
export const USER_UPDATED_ROUTING_KEY = 'user.updated';
export const USER_DELETED_ROUTING_KEY = 'user.deleted';

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