working version, with basic tests

This commit is contained in:
sbriat 2023-06-21 11:50:36 +02:00
parent e1989c0a52
commit b232247c93
43 changed files with 217 additions and 1060 deletions

View File

@ -94,17 +94,14 @@
"ts"
],
"modulePathIgnorePatterns": [
".controller.ts",
"libs/",
".module.ts",
".request.ts",
".presenter.ts",
".profile.ts",
".exception.ts",
".dto.ts",
".constants.ts",
"main.ts"
],
"rootDir": "src",
"testRegex": ".*\\.service.spec\\.ts$",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
@ -112,21 +109,17 @@
"**/*.(t|j)s"
],
"coveragePathIgnorePatterns": [
".validator.ts",
".controller.ts",
"libs/",
".module.ts",
".request.ts",
".presenter.ts",
".profile.ts",
".exception.ts",
".dto.ts",
".constants.ts",
".interfaces.ts",
"main.ts"
],
"coverageDirectory": "../coverage",
"moduleNameMapper": {
"^@libs(.*)": "<rootDir>/libs/$1",
"^@modules(.*)": "<rootDir>/modules/$1"
"^@modules(.*)": "<rootDir>/modules/$1",
"^@src(.*)": "<rootDir>$1"
},
"testEnvironment": "node"
}

View File

@ -4,6 +4,8 @@ import { ObjectLiteral } from '../types';
import { LoggerPort } from '../ports/logger.port';
import { None, Option, Some } from 'oxide.ts';
import { PrismaRepositoryPort } from '../ports/prisma-repository.port';
import { Prisma } from '@prisma/client';
import { ConflictException, DatabaseErrorException } from '@libs/exceptions';
export abstract class PrismaRepositoryBase<
Aggregate extends AggregateRoot<any>,
@ -17,16 +19,11 @@ export abstract class PrismaRepositoryBase<
protected readonly logger: LoggerPort,
) {}
async findOneById(uuid: string): Promise<Option<Aggregate>> {
try {
const entity = await this.prisma.findUnique({
where: { uuid },
});
return entity ? Some(this.mapper.toDomain(entity)) : None;
} catch (e) {
console.log('ouch on findOneById');
}
async findOneById(id: string): Promise<Option<Aggregate>> {
const entity = await this.prisma.findUnique({
where: { uuid: id },
});
return entity ? Some(this.mapper.toDomain(entity)) : None;
}
async insert(entity: Aggregate): Promise<void> {
@ -35,12 +32,24 @@ export abstract class PrismaRepositoryBase<
data: this.mapper.toPersistence(entity),
});
} catch (e) {
console.log(e);
console.log('ouch on insert');
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.message.includes('Already exists')) {
throw new ConflictException('Record already exists', e);
}
}
throw e;
}
}
async healthCheck(): Promise<boolean> {
return true;
try {
await this.prisma.$queryRaw`SELECT 1`;
return true;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseErrorException(e.message);
}
throw new DatabaseErrorException();
}
}
}

View File

@ -13,3 +13,4 @@ export const ARGUMENT_NOT_PROVIDED = 'GENERIC.ARGUMENT_NOT_PROVIDED';
export const NOT_FOUND = 'GENERIC.NOT_FOUND';
export const CONFLICT = 'GENERIC.CONFLICT';
export const INTERNAL_SERVER_ERROR = 'GENERIC.INTERNAL_SERVER_ERROR';
export const DATABASE_ERROR = 'GENERIC.DATABASE_ERROR';

View File

@ -3,6 +3,7 @@ import {
ARGUMENT_NOT_PROVIDED,
ARGUMENT_OUT_OF_RANGE,
CONFLICT,
DATABASE_ERROR,
INTERNAL_SERVER_ERROR,
NOT_FOUND,
} from '.';
@ -80,3 +81,19 @@ export class InternalServerErrorException extends ExceptionBase {
readonly code = INTERNAL_SERVER_ERROR;
}
/**
* Used to indicate a database error
*
* @class DatabaseErrorException
* @extends {ExceptionBase}
*/
export class DatabaseErrorException extends ExceptionBase {
static readonly message = 'Database error';
constructor(message = DatabaseErrorException.message) {
super(message);
}
readonly code = DATABASE_ERROR;
}

View File

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

View File

@ -1,24 +1,26 @@
import { Module } from '@nestjs/common';
import { CreateAdGrpcController } from './interface/grpc-controllers/create-ad.grpc.controller';
import { DatabaseModule } from '../database/database.module';
import { CqrsModule } from '@nestjs/cqrs';
import {
AD_REPOSITORY,
PARAMS_PROVIDER,
TIMEZONE_FINDER,
} from './ad.di-tokens';
import { MESSAGE_BROKER_PUBLISHER, MESSAGE_PUBLISHER } from 'src/app.constants';
import {
MESSAGE_BROKER_PUBLISHER,
MESSAGE_PUBLISHER,
} from '@src/app.constants';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
import { AdRepository } from './infrastructure/ad.repository';
import { DefaultParamsProvider } from './infrastructure/default-params-provider';
import { MessagePublisher } from './infrastructure/message-publisher';
import { PrismaService } from './infrastructure/prisma-service';
import { AdMapper } from './ad.mapper';
import { CreateAdService } from './core/commands/create-ad/create-ad.service';
import { TimezoneFinder } from './infrastructure/timezone-finder';
import { PrismaService } from '@libs/db/prisma.service';
@Module({
imports: [DatabaseModule, CqrsModule],
imports: [CqrsModule],
controllers: [CreateAdGrpcController],
providers: [
CreateAdService,

View File

@ -1,10 +1,10 @@
import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AdRepositoryPort } from '../core/ports/ad.repository.port';
import { AdEntity } from '../core/ad.entity';
import { PrismaRepositoryBase } from '@libs/db/prisma-repository.base';
import { AdRepositoryPort } from '../core/ports/ad.repository.port';
import { PrismaService } from '@libs/db/prisma.service';
import { AdMapper } from '../ad.mapper';
import { PrismaService } from './prisma-service';
import { PrismaRepositoryBase } from '@libs/db/prisma-repository.base';
export type AdModel = {
uuid: string;

View File

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

View File

@ -9,6 +9,7 @@ import { CreateAdCommand } from '@modules/ad/core/commands/create-ad/create-ad.c
import { Result } from 'oxide.ts';
import { AggregateID } from '@libs/ddd';
import { AdAlreadyExistsError } from '@modules/ad/core/ad.errors';
import { AdEntity } from '@modules/ad/core/ad.entity';
const originWaypoint: WaypointDTO = {
position: 0,
@ -43,11 +44,7 @@ const punctualCreateAdRequest: CreateAdRequestDTO = {
};
const mockAdRepository = {
insert: jest.fn().mockImplementationOnce(() => {
return Promise.resolve({
uuid: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
}),
insert: jest.fn(),
};
const mockDefaultParamsProvider: DefaultParamsProviderPort = {
@ -98,6 +95,9 @@ describe('create-ad.service', () => {
describe('execution', () => {
const createAdCommand = new CreateAdCommand(punctualCreateAdRequest);
it('should create a new ad', async () => {
AdEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
const result: Result<AggregateID, AdAlreadyExistsError> =
await createAdService.execute(createAdCommand);
expect(result.unwrap()).toBe('047a6ecf-23d4-4d3e-877c-3225d560a8da');

View File

@ -1,12 +1,29 @@
import { DefaultParams } from '@modules/ad/core/ports/default-params.type';
import { DefaultParamsProvider } from '@modules/ad/infrastructure/default-params-provider';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { DefaultParamsProvider } from '../../../../adapters/secondaries/default-params-provider';
import { DefaultParams } from '../../../../core/ports/default-params.type';
const mockConfigService = {
get: jest.fn().mockImplementation(() => 'some_default_value'),
get: jest.fn().mockImplementation((value: string) => {
switch (value) {
case 'DEPARTURE_MARGIN':
return 900;
case 'ROLE':
return 'passenger';
case 'SEATS_PROPOSED':
return 3;
case 'SEATS_REQUESTED':
return 1;
case 'STRICT_FREQUENCY':
return 'false';
case 'DEFAULT_TIMEZONE':
return 'Europe/Paris';
default:
return 'some_default_value';
}
}),
};
//TODO complete coverage
describe('DefaultParamsProvider', () => {
let defaultParamsProvider: DefaultParamsProvider;
@ -33,8 +50,9 @@ describe('DefaultParamsProvider', () => {
it('should provide default params', async () => {
const params: DefaultParams = defaultParamsProvider.getParams();
expect(params.SUN_MARGIN).toBeNaN();
expect(params.PASSENGER).toBe(false);
expect(params.DRIVER).toBe(false);
expect(params.SUN_MARGIN).toBe(900);
expect(params.PASSENGER).toBeTruthy();
expect(params.DRIVER).toBeFalsy();
expect(params.DEFAULT_TIMEZONE).toBe('Europe/Paris');
});
});

View File

@ -1,6 +1,6 @@
import { MessagePublisher } from '@modules/ad/infrastructure/message-publisher';
import { Test, TestingModule } from '@nestjs/testing';
import { MessagePublisher } from '../../../../adapters/secondaries/message-publisher';
import { MESSAGE_BROKER_PUBLISHER } from '../../../../../../app.constants';
import { MESSAGE_BROKER_PUBLISHER } from '@src/app.constants';
const mockMessageBrokerPublisher = {
publish: jest.fn().mockImplementation(),

View File

@ -0,0 +1,14 @@
import { TimezoneFinder } from '@modules/ad/infrastructure/timezone-finder';
describe('Timezone Finder', () => {
it('should be defined', () => {
const timezoneFinder: TimezoneFinder = new TimezoneFinder();
expect(timezoneFinder).toBeDefined();
});
it('should get timezone for Nancy(France) as Europe/Paris', () => {
const timezoneFinder: TimezoneFinder = new TimezoneFinder();
const timezones = timezoneFinder.timezones(6.179373, 48.687913);
expect(timezones.length).toBe(1);
expect(timezones[0]).toBe('Europe/Paris');
});
});

View File

@ -1,258 +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) {}
async findAll(
page = 1,
perPage = 10,
where?: any,
include?: any,
): Promise<ICollection<T>> {
const [data, total] = await this._prisma.$transaction([
this._prisma[this.model].findMany({
where,
include,
skip: (page - 1) * perPage,
take: perPage,
}),
this._prisma[this.model].count({
where,
}),
]);
return Promise.resolve({
data,
total,
});
}
async findOneByUuid(uuid: string): Promise<T> {
try {
const entity = await this._prisma[this.model].findUnique({
where: { uuid },
});
return entity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
}
async findOne(where: any, include?: any): Promise<T> {
try {
const entity = await this._prisma[this.model].findFirst({
where: where,
include: include,
});
return entity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
);
} else {
throw new DatabaseException();
}
}
}
// TODO : using any is not good, but needed for nested entities
// TODO : Refactor for good clean architecture ?
async create(entity: Partial<T> | any, include?: any): Promise<T> {
try {
const res = await this._prisma[this.model].create({
data: entity,
include: include,
});
return res;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
}
async update(uuid: string, entity: Partial<T>): Promise<T> {
try {
const updatedEntity = await this._prisma[this.model].update({
where: { uuid },
data: entity,
});
return updatedEntity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
}
async updateWhere(
where: any,
entity: Partial<T> | any,
include?: any,
): Promise<T> {
try {
const updatedEntity = await this._prisma[this.model].update({
where: where,
data: entity,
include: include,
});
return updatedEntity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
}
async delete(uuid: string): Promise<T> {
try {
const entity = await this._prisma[this.model].delete({
where: { uuid },
});
return entity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
}
async deleteMany(where: any): Promise<void> {
try {
const entity = await this._prisma[this.model].deleteMany({
where: where,
});
return entity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
}
async findAllByQuery(
include: string[],
where: string[],
): Promise<ICollection<T>> {
const query = `SELECT ${include.join(',')} FROM ${
this.model
} WHERE ${where.join(' AND ')}`;
const data: T[] = await this._prisma.$queryRawUnsafe(query);
return Promise.resolve({
data,
total: data.length,
});
}
async createWithFields(fields: object): Promise<number> {
try {
const command = `INSERT INTO ${this.model} ("${Object.keys(fields).join(
'","',
)}") VALUES (${Object.values(fields).join(',')})`;
return await this._prisma.$executeRawUnsafe(command);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
}
async updateWithFields(uuid: string, entity: object): Promise<number> {
entity['"updatedAt"'] = `to_timestamp(${Date.now()} / 1000.0)`;
const values = Object.keys(entity).map((key) => `${key} = ${entity[key]}`);
try {
const command = `UPDATE ${this.model} SET ${values.join(
', ',
)} WHERE uuid = '${uuid}'`;
return await this._prisma.$executeRawUnsafe(command);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
}
async healthCheck(): Promise<boolean> {
try {
await this._prisma.$queryRaw`SELECT 1`;
return true;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
}
}

View File

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

View File

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

View File

@ -1,3 +0,0 @@
import { PrismaRepository } from '../adapters/secondaries/prisma-repository.abstract';
export class AdRepository<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,37 +0,0 @@
import { Controller, Get, Inject } from '@nestjs/common';
import {
HealthCheckService,
HealthCheck,
HealthCheckResult,
} from '@nestjs/terminus';
import { MESSAGE_PUBLISHER } from 'src/app.constants';
import { RepositoriesHealthIndicatorUseCase } from '../../domain/usecases/repositories.health-indicator.usecase';
import { MessagePublisherPort } from '@ports/message-publisher.port';
@Controller('health')
export class HealthController {
constructor(
private readonly repositoriesHealthIndicatorUseCase: RepositoriesHealthIndicatorUseCase,
private healthCheckService: HealthCheckService,
@Inject(MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
) {}
@Get()
@HealthCheck()
async check() {
try {
return await this.healthCheckService.check([
async () =>
this.repositoriesHealthIndicatorUseCase.isHealthy('repositories'),
]);
} catch (error) {
const healthCheckResult: HealthCheckResult = error.response;
this.messagePublisher.publish(
'logging.user.health.crit',
JSON.stringify(healthCheckResult.error),
);
throw error;
}
}
}

View File

@ -0,0 +1,3 @@
export interface CheckRepositoryPort {
healthCheck(): Promise<boolean>;
}

View File

@ -0,0 +1,48 @@
import { Inject, Injectable } from '@nestjs/common';
import {
HealthCheckError,
HealthCheckResult,
HealthIndicator,
HealthIndicatorResult,
} from '@nestjs/terminus';
import { CheckRepositoryPort } from '../ports/check-repository.port';
import { AD_REPOSITORY } from '@modules/health/health.di-tokens';
import { AdRepositoryPort } from '@modules/ad/core/ports/ad.repository.port';
import { MESSAGE_PUBLISHER } from '@src/app.constants';
import { MessagePublisherPort } from '@ports/message-publisher.port';
import { LOGGING_AD_HEALTH_CRIT } from '@modules/health/health.constants';
@Injectable()
export class RepositoriesHealthIndicatorUseCase extends HealthIndicator {
private checkRepositories: CheckRepositoryPort[];
constructor(
@Inject(AD_REPOSITORY)
private readonly adRepository: AdRepositoryPort,
@Inject(MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
) {
super();
this.checkRepositories = [adRepository];
}
isHealthy = async (key: string): Promise<HealthIndicatorResult> => {
try {
await Promise.all(
this.checkRepositories.map(
async (checkRepository: CheckRepositoryPort) => {
await checkRepository.healthCheck();
},
),
);
return this.getStatus(key, true);
} catch (error) {
const healthCheckResult: HealthCheckResult = error;
this.messagePublisher.publish(
LOGGING_AD_HEALTH_CRIT,
JSON.stringify(healthCheckResult.error),
);
throw new HealthCheckError('Repository', {
repository: error.message,
});
}
};
}

View File

@ -1,3 +0,0 @@
export interface ICheckRepository {
healthCheck(): Promise<boolean>;
}

View File

@ -1,33 +0,0 @@
import { Injectable } from '@nestjs/common';
import {
HealthCheckError,
HealthIndicator,
HealthIndicatorResult,
} from '@nestjs/terminus';
import { ICheckRepository } from '../interfaces/check-repository.interface';
import { AdRepository } from '@modules/ad/infrastructure/ad.repository';
@Injectable()
export class RepositoriesHealthIndicatorUseCase extends HealthIndicator {
private checkRepositories: ICheckRepository[];
constructor(private readonly adRepository: AdRepository) {
super();
this.checkRepositories = [adRepository];
}
isHealthy = async (key: string): Promise<HealthIndicatorResult> => {
try {
await Promise.all(
this.checkRepositories.map(
async (checkRepository: ICheckRepository) => {
await checkRepository.healthCheck();
},
),
);
return this.getStatus(key, true);
} catch (e: any) {
throw new HealthCheckError('Repository', {
repository: e.message,
});
}
};
}

View File

@ -0,0 +1 @@
export const LOGGING_AD_HEALTH_CRIT = 'logging.ad.health.crit';

View File

@ -0,0 +1 @@
export const AD_REPOSITORY = Symbol('AD_REPOSITORY');

View File

@ -1,20 +1,23 @@
import { Module } from '@nestjs/common';
import { HealthServerController } from './adapters/primaries/health-server.controller';
import { DatabaseModule } from '../database/database.module';
import { HealthController } from './adapters/primaries/health.controller';
import { HealthHttpController } from './interface/http-controllers/health.http.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';
import { RepositoriesHealthIndicatorUseCase } from './domain/usecases/repositories.health-indicator.usecase';
import { MessagePublisher } from './infrastructure/message-publisher';
import { RepositoriesHealthIndicatorUseCase } from './core/usecases/repositories.health-indicator.usecase';
import { AdRepository } from '../ad/infrastructure/ad.repository';
import { AD_REPOSITORY } from './health.di-tokens';
import { HealthGrpcController } from './interface/grpc-controllers/health.grpc.controller';
@Module({
imports: [TerminusModule, DatabaseModule],
controllers: [HealthServerController, HealthController],
imports: [TerminusModule],
controllers: [HealthGrpcController, HealthHttpController],
providers: [
RepositoriesHealthIndicatorUseCase,
AdRepository,
{
provide: AD_REPOSITORY,
useClass: AdRepository,
},
{
provide: MESSAGE_BROKER_PUBLISHER,
useClass: MessageBrokerPublisher,

View File

@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { MESSAGE_BROKER_PUBLISHER } from '../../../../app.constants';
import { MESSAGE_BROKER_PUBLISHER } from '../../../app.constants';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
import { MessagePublisherPort } from '@ports/message-publisher.port';

View File

@ -1,6 +1,6 @@
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import { RepositoriesHealthIndicatorUseCase } from '../../domain/usecases/repositories.health-indicator.usecase';
import { RepositoriesHealthIndicatorUseCase } from '../../core/usecases/repositories.health-indicator.usecase';
enum ServingStatus {
UNKNOWN = 0,
@ -17,7 +17,7 @@ interface HealthCheckResponse {
}
@Controller()
export class HealthServerController {
export class HealthGrpcController {
constructor(
private readonly repositoriesHealthIndicatorUseCase: RepositoriesHealthIndicatorUseCase,
) {}

View File

@ -0,0 +1,24 @@
import { Controller, Get } from '@nestjs/common';
import { HealthCheckService, HealthCheck } from '@nestjs/terminus';
import { RepositoriesHealthIndicatorUseCase } from '../../core/usecases/repositories.health-indicator.usecase';
@Controller('health')
export class HealthHttpController {
constructor(
private readonly repositoriesHealthIndicatorUseCase: RepositoriesHealthIndicatorUseCase,
private healthCheckService: HealthCheckService,
) {}
@Get()
@HealthCheck()
async check() {
try {
return await this.healthCheckService.check([
async () =>
this.repositoriesHealthIndicatorUseCase.isHealthy('repositories'),
]);
} catch (error) {
throw error;
}
}
}

View File

@ -1,6 +1,6 @@
import { MessagePublisher } from '@modules/health/infrastructure/message-publisher';
import { Test, TestingModule } from '@nestjs/testing';
import { MessagePublisher } from '../../adapters/secondaries/message-publisher';
import { MESSAGE_BROKER_PUBLISHER } from '../../../../app.constants';
import { MESSAGE_BROKER_PUBLISHER } from '@src/app.constants';
const mockMessageBrokerPublisher = {
publish: jest.fn().mockImplementation(),

View File

@ -1,19 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HealthCheckError, HealthIndicatorResult } from '@nestjs/terminus';
import { RepositoriesHealthIndicatorUseCase } from '../../domain/usecases/repositories.health-indicator.usecase';
import { AdsRepository } from '../../../ad/adapters/secondaries/ads.repository';
import { RepositoriesHealthIndicatorUseCase } from '../../core/usecases/repositories.health-indicator.usecase';
import { AD_REPOSITORY } from '@modules/health/health.di-tokens';
import { MESSAGE_PUBLISHER } from '@src/app.constants';
import { DatabaseErrorException } from '@libs/exceptions';
const mockAdsRepository = {
const mockAdRepository = {
healthCheck: jest
.fn()
.mockImplementationOnce(() => {
return Promise.resolve(true);
})
.mockImplementation(() => {
throw new Error('an error occured in the repository');
throw new DatabaseErrorException('an error occured in the database');
}),
};
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('RepositoriesHealthIndicatorUseCase', () => {
let repositoriesHealthIndicatorUseCase: RepositoriesHealthIndicatorUseCase;
@ -22,8 +28,12 @@ describe('RepositoriesHealthIndicatorUseCase', () => {
providers: [
RepositoriesHealthIndicatorUseCase,
{
provide: AdsRepository,
useValue: mockAdsRepository,
provide: AD_REPOSITORY,
useValue: mockAdRepository,
},
{
provide: MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
],
}).compile();
@ -42,7 +52,6 @@ describe('RepositoriesHealthIndicatorUseCase', () => {
it('should check health successfully', async () => {
const healthIndicatorResult: HealthIndicatorResult =
await repositoriesHealthIndicatorUseCase.isHealthy('repositories');
expect(healthIndicatorResult['repositories'].status).toBe('up');
});

View File

@ -22,6 +22,7 @@
"@modules/*": ["src/modules/*"],
"@ports/*": ["src/ports/*"],
"@utils/*": ["src/utils/*"],
"@src/*": ["src/*"],
}
}
}