new authorization

This commit is contained in:
sbriat
2023-07-06 16:23:18 +02:00
parent bbcd2cdb9e
commit 470a93879e
97 changed files with 847 additions and 172 deletions

View File

@@ -1,52 +0,0 @@
import { RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
import { Controller } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { UpdateUsernameCommand } from '../../commands/update-username.command';
import { Type } from '../../domain/dtos/type.enum';
import { UpdateUsernameRequest } from '../../domain/dtos/update-username.request';
import { DeleteAuthenticationRequest } from '../../domain/dtos/delete-authentication.request';
import { DeleteAuthenticationCommand } from '../../commands/delete-authentication.command';
@Controller()
export class AuthenticationMessagerController {
constructor(private readonly commandBus: CommandBus) {}
@RabbitSubscribe({
name: 'userUpdate',
})
public async userUpdatedHandler(message: string) {
const updatedUser = JSON.parse(message);
if (!updatedUser.hasOwnProperty('uuid')) throw new Error();
if (updatedUser.hasOwnProperty('email') && updatedUser.email) {
const updateUsernameRequest = new UpdateUsernameRequest();
updateUsernameRequest.uuid = updatedUser.uuid;
updateUsernameRequest.username = updatedUser.email;
updateUsernameRequest.type = Type.EMAIL;
await this.commandBus.execute(
new UpdateUsernameCommand(updateUsernameRequest),
);
}
if (updatedUser.hasOwnProperty('phone') && updatedUser.phone) {
const updateUsernameRequest = new UpdateUsernameRequest();
updateUsernameRequest.uuid = updatedUser.uuid;
updateUsernameRequest.username = updatedUser.phone;
updateUsernameRequest.type = Type.PHONE;
await this.commandBus.execute(
new UpdateUsernameCommand(updateUsernameRequest),
);
}
}
@RabbitSubscribe({
name: 'userDelete',
})
public async userDeletedHandler(message: string) {
const deletedUser = JSON.parse(message);
if (!deletedUser.hasOwnProperty('uuid')) throw new Error();
const deleteAuthenticationRequest = new DeleteAuthenticationRequest();
deleteAuthenticationRequest.uuid = deletedUser.uuid;
await this.commandBus.execute(
new DeleteAuthenticationCommand(deleteAuthenticationRequest),
);
}
}

View File

@@ -1,188 +0,0 @@
import { Mapper } from '@automapper/core';
import { InjectMapper } from '@automapper/nestjs';
import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { DatabaseException } from 'src/modules/database/exceptions/database.exception';
import { AddUsernameCommand } from '../../commands/add-username.command';
import { CreateAuthenticationCommand } from '../../commands/create-authentication.command';
import { DeleteAuthenticationCommand } from '../../commands/delete-authentication.command';
import { DeleteUsernameCommand } from '../../commands/delete-username.command';
import { UpdatePasswordCommand } from '../../commands/update-password.command';
import { UpdateUsernameCommand } from '../../commands/update-username.command';
import { AddUsernameRequest } from '../../domain/dtos/add-username.request';
import { CreateAuthenticationRequest } from '../../domain/dtos/create-authentication.request';
import { DeleteAuthenticationRequest } from '../../domain/dtos/delete-authentication.request';
import { DeleteUsernameRequest } from '../../domain/dtos/delete-username.request';
import { UpdatePasswordRequest } from '../../domain/dtos/update-password.request';
import { UpdateUsernameRequest } from '../../domain/dtos/update-username.request';
import { ValidateAuthenticationRequest } from '../../domain/dtos/validate-authentication.request';
import { Authentication } from '../../domain/entities/authentication';
import { Username } from '../../domain/entities/username';
import { ValidateAuthenticationQuery } from '../../queries/validate-authentication.query';
import { AuthenticationPresenter } from './authentication.presenter';
import { RpcValidationPipe } from '../../../../utils/pipes/rpc.validation-pipe';
import { UsernamePresenter } from './username.presenter';
@UsePipes(
new RpcValidationPipe({
whitelist: true,
forbidUnknownValues: false,
}),
)
@Controller()
export class AuthenticationController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
@InjectMapper() private readonly mapper: Mapper,
) {}
@GrpcMethod('AuthenticationService', 'Validate')
async validate(
data: ValidateAuthenticationRequest,
): Promise<AuthenticationPresenter> {
try {
const authentication: Authentication = await this.queryBus.execute(
new ValidateAuthenticationQuery(data.username, data.password),
);
return this.mapper.map(
authentication,
Authentication,
AuthenticationPresenter,
);
} catch (e) {
throw new RpcException({
code: 7,
message: 'Permission denied',
});
}
}
@GrpcMethod('AuthenticationService', 'Create')
async createUser(
data: CreateAuthenticationRequest,
): Promise<AuthenticationPresenter> {
try {
const authentication: Authentication = await this.commandBus.execute(
new CreateAuthenticationCommand(data),
);
return this.mapper.map(
authentication,
Authentication,
AuthenticationPresenter,
);
} catch (e) {
if (e instanceof DatabaseException) {
if (e.message.includes('Already exists')) {
throw new RpcException({
code: 6,
message: 'Auth already exists',
});
}
}
throw new RpcException({
code: 7,
message: 'Permission denied',
});
}
}
@GrpcMethod('AuthenticationService', 'AddUsername')
async addUsername(data: AddUsernameRequest): Promise<UsernamePresenter> {
try {
const username: Username = await this.commandBus.execute(
new AddUsernameCommand(data),
);
return this.mapper.map(username, Username, UsernamePresenter);
} catch (e) {
if (e instanceof DatabaseException) {
if (e.message.includes('Already exists')) {
throw new RpcException({
code: 6,
message: 'Username already exists',
});
}
}
throw new RpcException({
code: 7,
message: 'Permission denied',
});
}
}
@GrpcMethod('AuthenticationService', 'UpdateUsername')
async updateUsername(
data: UpdateUsernameRequest,
): Promise<UsernamePresenter> {
try {
const username: Username = await this.commandBus.execute(
new UpdateUsernameCommand(data),
);
return this.mapper.map(username, Username, UsernamePresenter);
} catch (e) {
if (e instanceof DatabaseException) {
if (e.message.includes('Already exists')) {
throw new RpcException({
code: 6,
message: 'Username already exists',
});
}
}
throw new RpcException({
code: 7,
message: 'Permission denied',
});
}
}
@GrpcMethod('AuthenticationService', 'UpdatePassword')
async updatePassword(
data: UpdatePasswordRequest,
): Promise<AuthenticationPresenter> {
try {
const authentication: Authentication = await this.commandBus.execute(
new UpdatePasswordCommand(data),
);
return this.mapper.map(
authentication,
Authentication,
AuthenticationPresenter,
);
} catch (e) {
throw new RpcException({
code: 7,
message: 'Permission denied',
});
}
}
@GrpcMethod('AuthenticationService', 'DeleteUsername')
async deleteUsername(data: DeleteUsernameRequest) {
try {
return await this.commandBus.execute(new DeleteUsernameCommand(data));
} catch (e) {
throw new RpcException({
code: 7,
message: 'Permission denied',
});
}
}
@GrpcMethod('AuthenticationService', 'Delete')
async deleteAuthentication(data: DeleteAuthenticationRequest) {
try {
return await this.commandBus.execute(
new DeleteAuthenticationCommand(data),
);
} catch (e) {
throw new RpcException({
code: 7,
message: 'Permission denied',
});
}
}
}

View File

@@ -1,6 +0,0 @@
import { AutoMap } from '@automapper/classes';
export class AuthenticationPresenter {
@AutoMap()
uuid: string;
}

View File

@@ -1,42 +0,0 @@
syntax = "proto3";
package authentication;
service AuthenticationService {
rpc Validate(AuthenticationByUsernamePassword) returns (Uuid);
rpc Create(Authentication) returns (Uuid);
rpc AddUsername(Username) returns (Uuid);
rpc UpdatePassword(Password) returns (Uuid);
rpc UpdateUsername(Username) returns (Uuid);
rpc DeleteUsername(Username) returns (Uuid);
rpc Delete(Uuid) returns (Empty);
}
message AuthenticationByUsernamePassword {
string username = 1;
string password = 2;
}
message Authentication {
string uuid = 1;
string username = 2;
string password = 3;
string type = 4;
}
message Password {
string uuid = 1;
string password = 2;
}
message Username {
string uuid = 1;
string username = 2;
string type = 3;
}
message Uuid {
string uuid = 1;
}
message Empty {}

View File

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

View File

@@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AuthRepository } from '../../../database/domain/auth-repository';
import { Authentication } from '../../domain/entities/authentication';
@Injectable()
export class AuthenticationRepository extends AuthRepository<Authentication> {
protected model = 'auth';
}

View File

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

View File

@@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AuthRepository } from '../../../database/domain/auth-repository';
import { Username } from '../../domain/entities/username';
@Injectable()
export class UsernameRepository extends AuthRepository<Username> {
protected model = 'username';
}

View File

@@ -0,0 +1,2 @@
export const AUTHENTICATION_REPOSITORY = Symbol('AUTHENTICATION_REPOSITORY');
export const USERNAME_REPOSITORY = Symbol('USERNAME_REPOSITORY');

View File

@@ -0,0 +1,68 @@
import { Mapper } from '@mobicoop/ddd-library';
import { Injectable } from '@nestjs/common';
import { AuthenticationEntity } from './core/domain/authentication.entity';
import { AuthenticationResponseDto } from './interface/dtos/authentication.response.dto';
import {
AuthenticationReadModel,
AuthenticationWriteModel,
UsernameModel,
} from './infrastructure/authentication.repository';
import { Type, UsernameProps } from './core/domain/username.types';
/**
* Mapper constructs objects that are used in different layers:
* Record is an object that is stored in a database,
* Entity is an object that is used in application domain layer,
* and a ResponseDTO is an object returned to a user (usually as json).
*/
@Injectable()
export class AuthenticationMapper
implements
Mapper<
AuthenticationEntity,
AuthenticationReadModel,
AuthenticationWriteModel,
AuthenticationResponseDto
>
{
toPersistence = (entity: AuthenticationEntity): AuthenticationWriteModel => {
const copy = entity.getProps();
const record: AuthenticationWriteModel = {
uuid: copy.id,
password: copy.password,
usernames: {
create: copy.usernames.map((username: UsernameProps) => ({
username: username.name,
type: username.type,
})),
},
createdAt: copy.createdAt,
updatedAt: copy.updatedAt,
};
return record;
};
toDomain = (record: AuthenticationReadModel): AuthenticationEntity => {
const entity = new AuthenticationEntity({
id: record.uuid,
createdAt: new Date(record.createdAt),
updatedAt: new Date(record.updatedAt),
props: {
userId: record.uuid,
password: record.password,
usernames: record.usernames.map((username: UsernameModel) => ({
userId: record.uuid,
name: username.username,
type: Type[username.type],
})),
},
});
return entity;
};
toResponse = (entity: AuthenticationEntity): AuthenticationResponseDto => {
const response = new AuthenticationResponseDto(entity);
return response;
};
}

View File

@@ -1,66 +1,72 @@
import { Module } from '@nestjs/common';
import { Module, Provider } from '@nestjs/common';
import { CreateAuthenticationGrpcController } from './interface/grpc-controllers/create-authentication.grpc.controller';
import { CreateAuthenticationService } from './core/application/commands/create-authentication/create-authentication.service';
import { AuthenticationMapper } from './authentication.mapper';
import {
AUTHENTICATION_REPOSITORY,
USERNAME_REPOSITORY,
} from './authentication.di-tokens';
import { AuthenticationRepository } from './infrastructure/authentication.repository';
import { PrismaService } from './infrastructure/prisma.service';
import { CqrsModule } from '@nestjs/cqrs';
import { DatabaseModule } from '../database/database.module';
import { AuthenticationController } from './adapters/primaries/authentication.controller';
import { CreateAuthenticationUseCase } from './domain/usecases/create-authentication.usecase';
import { ValidateAuthenticationUseCase } from './domain/usecases/validate-authentication.usecase';
import { AuthenticationProfile } from './mappers/authentication.profile';
import { AuthenticationRepository } from './adapters/secondaries/authentication.repository';
import { UpdateUsernameUseCase } from './domain/usecases/update-username.usecase';
import { UsernameProfile } from './mappers/username.profile';
import { AddUsernameUseCase } from './domain/usecases/add-username.usecase';
import { UpdatePasswordUseCase } from './domain/usecases/update-password.usecase';
import { DeleteUsernameUseCase } from './domain/usecases/delete-username.usecase';
import { DeleteAuthenticationUseCase } from './domain/usecases/delete-authentication.usecase';
import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthenticationMessagerController } from './adapters/primaries/authentication-messager.controller';
import { Messager } from './adapters/secondaries/messager';
import { DeleteAuthenticationGrpcController } from './interface/grpc-controllers/delete-authentication.grpc.controller';
import { DeleteAuthenticationService } from './core/application/commands/delete-authentication/delete-authentication.service';
import { MESSAGE_PUBLISHER } from '@src/app.di-tokens';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
import { UsernameRepository } from './infrastructure/username.repository';
import { UsernameMapper } from './username.mapper';
import { AddUsernameGrpcController } from './interface/grpc-controllers/add-username.grpc.controller';
import { AddUsernameService } from './core/application/commands/add-usernames/add-username.service';
const grpcControllers = [
CreateAuthenticationGrpcController,
DeleteAuthenticationGrpcController,
AddUsernameGrpcController,
];
const commandHandlers: Provider[] = [
CreateAuthenticationService,
DeleteAuthenticationService,
AddUsernameService,
];
const mappers: Provider[] = [AuthenticationMapper, UsernameMapper];
const repositories: Provider[] = [
{
provide: AUTHENTICATION_REPOSITORY,
useClass: AuthenticationRepository,
},
{
provide: USERNAME_REPOSITORY,
useClass: UsernameRepository,
},
];
const messageBrokers: Provider[] = [
{
provide: MESSAGE_PUBLISHER,
useClass: MessageBrokerPublisher,
},
];
const orms: Provider[] = [PrismaService];
@Module({
imports: [
DatabaseModule,
CqrsModule,
RabbitMQModule.forRootAsync(RabbitMQModule, {
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
exchanges: [
{
name: configService.get<string>('RMQ_EXCHANGE'),
type: 'topic',
},
],
handlers: {
userUpdate: {
exchange: configService.get<string>('RMQ_EXCHANGE'),
routingKey: 'user.update',
},
userDelete: {
exchange: configService.get<string>('RMQ_EXCHANGE'),
routingKey: 'user.delete',
},
},
uri: configService.get<string>('RMQ_URI'),
connectionInitOptions: { wait: false },
enableControllerDiscovery: true,
}),
inject: [ConfigService],
}),
],
controllers: [AuthenticationController, AuthenticationMessagerController],
imports: [CqrsModule],
controllers: [...grpcControllers],
providers: [
AuthenticationProfile,
UsernameProfile,
AuthenticationRepository,
Messager,
ValidateAuthenticationUseCase,
CreateAuthenticationUseCase,
AddUsernameUseCase,
UpdateUsernameUseCase,
UpdatePasswordUseCase,
DeleteUsernameUseCase,
DeleteAuthenticationUseCase,
...commandHandlers,
...mappers,
...repositories,
...messageBrokers,
...orms,
],
exports: [
PrismaService,
AuthenticationMapper,
AUTHENTICATION_REPOSITORY,
USERNAME_REPOSITORY,
],
exports: [],
})
export class AuthenticationModule {}

View File

@@ -1,9 +0,0 @@
import { AddUsernameRequest } from '../domain/dtos/add-username.request';
export class AddUsernameCommand {
readonly addUsernameRequest: AddUsernameRequest;
constructor(request: AddUsernameRequest) {
this.addUsernameRequest = request;
}
}

View File

@@ -1,9 +0,0 @@
import { CreateAuthenticationRequest } from '../domain/dtos/create-authentication.request';
export class CreateAuthenticationCommand {
readonly createAuthenticationRequest: CreateAuthenticationRequest;
constructor(request: CreateAuthenticationRequest) {
this.createAuthenticationRequest = request;
}
}

View File

@@ -1,9 +0,0 @@
import { DeleteAuthenticationRequest } from '../domain/dtos/delete-authentication.request';
export class DeleteAuthenticationCommand {
readonly deleteAuthenticationRequest: DeleteAuthenticationRequest;
constructor(request: DeleteAuthenticationRequest) {
this.deleteAuthenticationRequest = request;
}
}

View File

@@ -1,9 +0,0 @@
import { DeleteUsernameRequest } from '../domain/dtos/delete-username.request';
export class DeleteUsernameCommand {
readonly deleteUsernameRequest: DeleteUsernameRequest;
constructor(request: DeleteUsernameRequest) {
this.deleteUsernameRequest = request;
}
}

View File

@@ -1,9 +0,0 @@
import { UpdatePasswordRequest } from '../domain/dtos/update-password.request';
export class UpdatePasswordCommand {
readonly updatePasswordRequest: UpdatePasswordRequest;
constructor(request: UpdatePasswordRequest) {
this.updatePasswordRequest = request;
}
}

View File

@@ -1,9 +0,0 @@
import { UpdateUsernameRequest } from '../domain/dtos/update-username.request';
export class UpdateUsernameCommand {
readonly updateUsernameRequest: UpdateUsernameRequest;
constructor(request: UpdateUsernameRequest) {
this.updateUsernameRequest = request;
}
}

View File

@@ -0,0 +1,13 @@
import { Command, CommandProps } from '@mobicoop/ddd-library';
import { Username } from '../../types/username';
export class AddUsernameCommand extends Command {
readonly userId: string;
readonly username: Username;
constructor(props: CommandProps<AddUsernameCommand>) {
super(props);
this.userId = props.userId;
this.username = props.username;
}
}

View File

@@ -0,0 +1,52 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import {
AggregateID,
ConflictException,
UniqueConstraintException,
} from '@mobicoop/ddd-library';
import {
AUTHENTICATION_REPOSITORY,
USERNAME_REPOSITORY,
} from '@modules/authentication/authentication.di-tokens';
import { AuthenticationRepositoryPort } from '../../ports/authentication.repository.port';
import { UsernameAlreadyExistsException } from '@modules/authentication/core/domain/authentication.errors';
import { AddUsernameCommand } from './add-username.command';
import { UsernameRepositoryPort } from '../../ports/username.repository.port';
import { UsernameEntity } from '@modules/authentication/core/domain/username.entity';
@CommandHandler(AddUsernameCommand)
export class AddUsernameService implements ICommandHandler {
constructor(
@Inject(AUTHENTICATION_REPOSITORY)
private readonly authenticationRepository: AuthenticationRepositoryPort,
@Inject(USERNAME_REPOSITORY)
private readonly usernameRepository: UsernameRepositoryPort,
) {}
async execute(command: AddUsernameCommand): Promise<AggregateID> {
await this.authenticationRepository.findOneById(command.userId, {
usernames: true,
});
try {
const newUsername = await UsernameEntity.create({
name: command.username.name,
userId: command.userId,
type: command.username.type,
});
await this.usernameRepository.insert(newUsername);
return newUsername.id;
} catch (error: any) {
if (error instanceof ConflictException) {
throw new UsernameAlreadyExistsException(error);
}
if (
error instanceof UniqueConstraintException &&
error.message.includes('username')
) {
throw new UsernameAlreadyExistsException(error);
}
throw error;
}
}
}

View File

@@ -0,0 +1,15 @@
import { Command, CommandProps } from '@mobicoop/ddd-library';
import { Username } from '../../types/username';
export class CreateAuthenticationCommand extends Command {
readonly userId: string;
readonly password: string;
readonly usernames: Username[];
constructor(props: CommandProps<CreateAuthenticationCommand>) {
super(props);
this.userId = props.userId;
this.password = props.password;
this.usernames = props.usernames;
}
}

View File

@@ -0,0 +1,53 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import {
AggregateID,
ConflictException,
UniqueConstraintException,
} from '@mobicoop/ddd-library';
import { CreateAuthenticationCommand } from './create-authentication.command';
import { AUTHENTICATION_REPOSITORY } from '@modules/authentication/authentication.di-tokens';
import { AuthenticationRepositoryPort } from '../../ports/authentication.repository.port';
import { AuthenticationEntity } from '@modules/authentication/core/domain/authentication.entity';
import {
AuthenticationAlreadyExistsException,
UsernameAlreadyExistsException,
} from '@modules/authentication/core/domain/authentication.errors';
@CommandHandler(CreateAuthenticationCommand)
export class CreateAuthenticationService implements ICommandHandler {
constructor(
@Inject(AUTHENTICATION_REPOSITORY)
private readonly authenticationRepository: AuthenticationRepositoryPort,
) {}
async execute(command: CreateAuthenticationCommand): Promise<AggregateID> {
const authentication: AuthenticationEntity =
await AuthenticationEntity.create({
userId: command.userId,
password: command.password,
usernames: command.usernames,
});
try {
await this.authenticationRepository.insert(authentication);
return authentication.getProps().userId;
} catch (error: any) {
if (error instanceof ConflictException) {
throw new AuthenticationAlreadyExistsException(error);
}
if (
error instanceof UniqueConstraintException &&
error.message.includes('uuid')
) {
throw new AuthenticationAlreadyExistsException(error);
}
if (
error instanceof UniqueConstraintException &&
error.message.includes('username')
) {
throw new UsernameAlreadyExistsException(error);
}
throw error;
}
}
}

View File

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

View File

@@ -0,0 +1,26 @@
import { Inject } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { DeleteAuthenticationCommand } from './delete-authentication.command';
import { AUTHENTICATION_REPOSITORY } from '@modules/authentication/authentication.di-tokens';
import { AuthenticationRepositoryPort } from '../../ports/authentication.repository.port';
import { AuthenticationEntity } from '@modules/authentication/core/domain/authentication.entity';
@CommandHandler(DeleteAuthenticationCommand)
export class DeleteAuthenticationService implements ICommandHandler {
constructor(
@Inject(AUTHENTICATION_REPOSITORY)
private readonly authenticationRepository: AuthenticationRepositoryPort,
) {}
async execute(command: DeleteAuthenticationCommand): Promise<boolean> {
const authentication: AuthenticationEntity =
await this.authenticationRepository.findOneById(command.userId, {
usernames: true,
});
authentication.delete();
const isDeleted: boolean = await this.authenticationRepository.delete(
authentication,
);
return isDeleted;
}
}

View File

@@ -0,0 +1,4 @@
import { RepositoryPort } from '@mobicoop/ddd-library';
import { AuthenticationEntity } from '@modules/authentication/core/domain/authentication.entity';
export type AuthenticationRepositoryPort = RepositoryPort<AuthenticationEntity>;

View File

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

View File

@@ -0,0 +1,6 @@
import { Type } from '@modules/authentication/core/domain/username.types';
export type Username = {
name: string;
type: Type;
};

View File

@@ -0,0 +1,43 @@
import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library';
import * as bcrypt from 'bcrypt';
import {
AuthenticationProps,
CreateAuthenticationProps,
} from './authentication.types';
import { AuthenticationCreatedDomainEvent } from './events/authentication-created.domain-event';
import { AuthenticationDeletedDomainEvent } from './events/authentication-deleted.domain-event';
export class AuthenticationEntity extends AggregateRoot<AuthenticationProps> {
protected readonly _id: AggregateID;
static create = async (
create: CreateAuthenticationProps,
): Promise<AuthenticationEntity> => {
const props: AuthenticationProps = { ...create };
const hash = await bcrypt.hash(props.password, 10);
const authentication = new AuthenticationEntity({
id: props.userId,
props: {
userId: props.userId,
password: hash,
usernames: props.usernames,
},
});
authentication.addEvent(
new AuthenticationCreatedDomainEvent({ aggregateId: props.userId }),
);
return authentication;
};
delete(): void {
this.addEvent(
new AuthenticationDeletedDomainEvent({
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,21 @@
import { ExceptionBase } from '@mobicoop/ddd-library';
export class AuthenticationAlreadyExistsException extends ExceptionBase {
static readonly message = 'Authentication already exists';
public readonly code = 'AUTHENTICATION.ALREADY_EXISTS';
constructor(cause?: Error, metadata?: unknown) {
super(AuthenticationAlreadyExistsException.message, cause, metadata);
}
}
export class UsernameAlreadyExistsException extends ExceptionBase {
static readonly message = 'Username already exists';
public readonly code = 'USERNAME.ALREADY_EXISTS';
constructor(cause?: Error, metadata?: unknown) {
super(UsernameAlreadyExistsException.message, cause, metadata);
}
}

View File

@@ -0,0 +1,15 @@
import { UsernameProps } from './username.types';
// All properties that an Authentication has
export interface AuthenticationProps {
userId: string;
password: string;
usernames: UsernameProps[];
}
// Properties that are needed for an Authentication creation
export interface CreateAuthenticationProps {
userId: string;
password: string;
usernames: UsernameProps[];
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
import { AggregateID, AggregateRoot } from '@mobicoop/ddd-library';
import { UsernameProps } from './username.types';
import { UsernameAddedDomainEvent } from './events/username-added.domain-event';
export class UsernameEntity extends AggregateRoot<UsernameProps> {
protected readonly _id: AggregateID;
static create = async (create: UsernameProps): Promise<UsernameEntity> => {
const props: UsernameProps = { ...create };
const username = new UsernameEntity({
id: props.name,
props: {
name: props.name,
userId: props.userId,
type: props.type,
},
});
username.addEvent(
new UsernameAddedDomainEvent({ aggregateId: props.name }),
);
return username;
};
validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database
}
}

View File

@@ -0,0 +1,11 @@
// All properties that a Username has
export interface UsernameProps {
name: string;
userId?: string;
type: Type;
}
export enum Type {
EMAIL = 'EMAIL',
PHONE = 'PHONE',
}

View File

@@ -1,20 +0,0 @@
import { AutoMap } from '@automapper/classes';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { Type } from './type.enum';
export class AddUsernameRequest {
@IsString()
@IsNotEmpty()
@AutoMap()
uuid: string;
@IsString()
@IsNotEmpty()
@AutoMap()
username: string;
@IsEnum(Type)
@IsNotEmpty()
@AutoMap()
type: Type;
}

View File

@@ -1,25 +0,0 @@
import { AutoMap } from '@automapper/classes';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { Type } from './type.enum';
export class CreateAuthenticationRequest {
@IsString()
@IsNotEmpty()
@AutoMap()
uuid: string;
@IsString()
@IsNotEmpty()
@AutoMap()
username: string;
@IsString()
@IsNotEmpty()
@AutoMap()
password: string;
@IsEnum(Type)
@IsNotEmpty()
@AutoMap()
type: Type;
}

View File

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

View File

@@ -1,9 +0,0 @@
import { AutoMap } from '@automapper/classes';
import { IsNotEmpty, IsString } from 'class-validator';
export class DeleteUsernameRequest {
@IsString()
@IsNotEmpty()
@AutoMap()
username: string;
}

View File

@@ -1,4 +0,0 @@
export enum Type {
EMAIL = 'EMAIL',
PHONE = 'PHONE',
}

View File

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

View File

@@ -1,20 +0,0 @@
import { AutoMap } from '@automapper/classes';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { Type } from './type.enum';
export class UpdateUsernameRequest {
@IsString()
@IsNotEmpty()
@AutoMap()
uuid: string;
@IsString()
@IsNotEmpty()
@AutoMap()
username: string;
@IsEnum(Type)
@IsNotEmpty()
@AutoMap()
type: Type;
}

View File

@@ -1,11 +0,0 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class ValidateAuthenticationRequest {
@IsString()
@IsNotEmpty()
username: string;
@IsString()
@IsNotEmpty()
password: string;
}

View File

@@ -1,8 +0,0 @@
import { AutoMap } from '@automapper/classes';
export class Authentication {
@AutoMap()
uuid: string;
password: string;
}

View File

@@ -1,13 +0,0 @@
import { AutoMap } from '@automapper/classes';
import { Type } from '../dtos/type.enum';
export class Username {
@AutoMap()
uuid: string;
@AutoMap()
username: string;
@AutoMap()
type: Type;
}

View File

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

View File

@@ -1,33 +0,0 @@
import { CommandHandler } from '@nestjs/cqrs';
import { Messager } from '../../adapters/secondaries/messager';
import { UsernameRepository } from '../../adapters/secondaries/username.repository';
import { AddUsernameCommand } from '../../commands/add-username.command';
import { Username } from '../entities/username';
@CommandHandler(AddUsernameCommand)
export class AddUsernameUseCase {
constructor(
private readonly usernameRepository: UsernameRepository,
private readonly messager: Messager,
) {}
execute = async (command: AddUsernameCommand): Promise<Username> => {
const { uuid, username, type } = command.addUsernameRequest;
try {
return await this.usernameRepository.create({
uuid,
type,
username,
});
} catch (error) {
this.messager.publish(
'logging.auth.username.add.warning',
JSON.stringify({
command,
error,
}),
);
throw error;
}
};
}

View File

@@ -1,46 +0,0 @@
import { CommandHandler } from '@nestjs/cqrs';
import { AuthenticationRepository } from '../../adapters/secondaries/authentication.repository';
import { CreateAuthenticationCommand } from '../../commands/create-authentication.command';
import { Authentication } from '../entities/authentication';
import * as bcrypt from 'bcrypt';
import { UsernameRepository } from '../../adapters/secondaries/username.repository';
import { Messager } from '../../adapters/secondaries/messager';
@CommandHandler(CreateAuthenticationCommand)
export class CreateAuthenticationUseCase {
constructor(
private readonly authenticationRepository: AuthenticationRepository,
private readonly usernameRepository: UsernameRepository,
private readonly messager: Messager,
) {}
execute = async (
command: CreateAuthenticationCommand,
): Promise<Authentication> => {
const { uuid, password, ...username } = command.createAuthenticationRequest;
const hash = await bcrypt.hash(password, 10);
try {
const auth = await this.authenticationRepository.create({
uuid,
password: hash,
});
await this.usernameRepository.create({
uuid,
...username,
});
return auth;
} catch (error) {
this.messager.publish(
'logging.auth.create.crit',
JSON.stringify({
command,
error,
}),
);
throw error;
}
};
}

View File

@@ -1,37 +0,0 @@
import { CommandHandler } from '@nestjs/cqrs';
import { AuthenticationRepository } from '../../adapters/secondaries/authentication.repository';
import { Messager } from '../../adapters/secondaries/messager';
import { UsernameRepository } from '../../adapters/secondaries/username.repository';
import { DeleteAuthenticationCommand } from '../../commands/delete-authentication.command';
import { Authentication } from '../entities/authentication';
@CommandHandler(DeleteAuthenticationCommand)
export class DeleteAuthenticationUseCase {
constructor(
private readonly authenticationRepository: AuthenticationRepository,
private readonly usernameRepository: UsernameRepository,
private readonly messager: Messager,
) {}
execute = async (
command: DeleteAuthenticationCommand,
): Promise<Authentication> => {
try {
await this.usernameRepository.deleteMany({
uuid: command.deleteAuthenticationRequest.uuid,
});
return await this.authenticationRepository.delete(
command.deleteAuthenticationRequest.uuid,
);
} catch (error) {
this.messager.publish(
'logging.auth.delete.crit',
JSON.stringify({
command,
error,
}),
);
throw error;
}
};
}

View File

@@ -1,38 +0,0 @@
import { UnauthorizedException } from '@nestjs/common';
import { CommandHandler } from '@nestjs/cqrs';
import { Messager } from '../../adapters/secondaries/messager';
import { UsernameRepository } from '../../adapters/secondaries/username.repository';
import { DeleteUsernameCommand } from '../../commands/delete-username.command';
@CommandHandler(DeleteUsernameCommand)
export class DeleteUsernameUseCase {
constructor(
private readonly usernameRepository: UsernameRepository,
private readonly messager: Messager,
) {}
execute = async (command: DeleteUsernameCommand): Promise<void> => {
try {
const { username } = command.deleteUsernameRequest;
const usernameFound = await this.usernameRepository.findOne({
username,
});
const usernames = await this.usernameRepository.findAll(1, 1, {
uuid: usernameFound.uuid,
});
if (usernames.total > 1) {
return await this.usernameRepository.deleteMany({ username });
}
throw new UnauthorizedException();
} catch (error) {
this.messager.publish(
'logging.auth.username.delete.warning',
JSON.stringify({
command,
error,
}),
);
throw error;
}
};
}

View File

@@ -1,34 +0,0 @@
import { CommandHandler } from '@nestjs/cqrs';
import { AuthenticationRepository } from '../../adapters/secondaries/authentication.repository';
import { Authentication } from '../entities/authentication';
import * as bcrypt from 'bcrypt';
import { UpdatePasswordCommand } from '../../commands/update-password.command';
import { Messager } from '../../adapters/secondaries/messager';
@CommandHandler(UpdatePasswordCommand)
export class UpdatePasswordUseCase {
constructor(
private readonly authenticationRepository: AuthenticationRepository,
private readonly messager: Messager,
) {}
execute = async (command: UpdatePasswordCommand): Promise<Authentication> => {
const { uuid, password } = command.updatePasswordRequest;
const hash = await bcrypt.hash(password, 10);
try {
return await this.authenticationRepository.update(uuid, {
password: hash,
});
} catch (error) {
this.messager.publish(
'logging.auth.password.update.warning',
JSON.stringify({
command,
error,
}),
);
throw error;
}
};
}

View File

@@ -1,67 +0,0 @@
import { Mapper } from '@automapper/core';
import { InjectMapper } from '@automapper/nestjs';
import { BadRequestException } from '@nestjs/common';
import { CommandBus, CommandHandler } from '@nestjs/cqrs';
import { Messager } from '../../adapters/secondaries/messager';
import { UsernameRepository } from '../../adapters/secondaries/username.repository';
import { AddUsernameCommand } from '../../commands/add-username.command';
import { UpdateUsernameCommand } from '../../commands/update-username.command';
import { AddUsernameRequest } from '../dtos/add-username.request';
import { UpdateUsernameRequest } from '../dtos/update-username.request';
import { Username } from '../entities/username';
@CommandHandler(UpdateUsernameCommand)
export class UpdateUsernameUseCase {
constructor(
private readonly usernameRepository: UsernameRepository,
private readonly commandBus: CommandBus,
@InjectMapper() private readonly mapper: Mapper,
private readonly messager: Messager,
) {}
execute = async (command: UpdateUsernameCommand): Promise<Username> => {
const { uuid, username, type } = command.updateUsernameRequest;
if (!username) throw new BadRequestException();
// update username if it exists, otherwise create it
const existingUsername = await this.usernameRepository.findOne({
uuid,
type,
});
if (existingUsername) {
try {
return await this.usernameRepository.updateWhere(
{
uuid_type: {
uuid,
type,
},
},
{
username,
},
);
} catch (error) {
this.messager.publish(
'logging.auth.username.update.warning',
JSON.stringify({
command,
error,
}),
);
throw error;
}
}
const addUsernameRequest = this.mapper.map(
command.updateUsernameRequest,
UpdateUsernameRequest,
AddUsernameRequest,
);
try {
return await this.commandBus.execute(
new AddUsernameCommand(addUsernameRequest),
);
} catch (e) {
throw e;
}
};
}

View File

@@ -1,41 +0,0 @@
import { QueryHandler } from '@nestjs/cqrs';
import { AuthenticationRepository } from '../../adapters/secondaries/authentication.repository';
import { ValidateAuthenticationQuery as ValidateAuthenticationQuery } from '../../queries/validate-authentication.query';
import { Authentication } from '../entities/authentication';
import * as bcrypt from 'bcrypt';
import { NotFoundException, UnauthorizedException } from '@nestjs/common';
import { UsernameRepository } from '../../adapters/secondaries/username.repository';
import { Username } from '../entities/username';
@QueryHandler(ValidateAuthenticationQuery)
export class ValidateAuthenticationUseCase {
constructor(
private readonly authenticationRepository: AuthenticationRepository,
private readonly usernameRepository: UsernameRepository,
) {}
execute = async (
validate: ValidateAuthenticationQuery,
): Promise<Authentication> => {
let username = new Username();
try {
username = await this.usernameRepository.findOne({
username: validate.username,
});
} catch (e) {
throw new NotFoundException();
}
try {
const auth = await this.authenticationRepository.findOne({
uuid: username.uuid,
});
if (auth) {
const isMatch = await bcrypt.compare(validate.password, auth.password);
if (isMatch) return auth;
}
throw new UnauthorizedException();
} catch (e) {
throw new UnauthorizedException();
}
};
}

View File

@@ -0,0 +1,67 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
LoggerBase,
MessagePublisherPort,
PrismaRepositoryBase,
} from '@mobicoop/ddd-library';
import { AuthenticationEntity } from '../core/domain/authentication.entity';
import { AuthenticationRepositoryPort } from '../core/application/ports/authentication.repository.port';
import { PrismaService } from './prisma.service';
import { AuthenticationMapper } from '../authentication.mapper';
import { MESSAGE_PUBLISHER } from '@src/app.di-tokens';
export type AuthenticationBaseModel = {
uuid: string;
password: string;
createdAt: Date;
updatedAt: Date;
};
export type AuthenticationReadModel = AuthenticationBaseModel & {
usernames: UsernameModel[];
};
export type AuthenticationWriteModel = AuthenticationBaseModel & {
usernames: {
create: UsernameModel[];
};
};
export type UsernameModel = {
username: string;
type: string;
};
/**
* Repository is used for retrieving/saving domain entities
* */
@Injectable()
export class AuthenticationRepository
extends PrismaRepositoryBase<
AuthenticationEntity,
AuthenticationReadModel,
AuthenticationWriteModel
>
implements AuthenticationRepositoryPort
{
constructor(
prisma: PrismaService,
mapper: AuthenticationMapper,
eventEmitter: EventEmitter2,
@Inject(MESSAGE_PUBLISHER)
protected readonly messagePublisher: MessagePublisherPort,
) {
super(
prisma.auth,
prisma,
mapper,
eventEmitter,
new LoggerBase({
logger: new Logger(AuthenticationRepository.name),
domain: 'auth',
messagePublisher,
}),
);
}
}

View File

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

View File

@@ -0,0 +1,49 @@
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 { MESSAGE_PUBLISHER } from '@src/app.di-tokens';
import { UsernameEntity } from '../core/domain/username.entity';
import { UsernameRepositoryPort } from '../core/application/ports/username.repository.port';
import { UsernameMapper } from '../username.mapper';
export type UsernameModel = {
username: string;
authUuid: string;
type: string;
createdAt: Date;
updatedAt: Date;
};
/**
* Repository is used for retrieving/saving domain entities
* */
@Injectable()
export class UsernameRepository
extends PrismaRepositoryBase<UsernameEntity, UsernameModel, UsernameModel>
implements UsernameRepositoryPort
{
constructor(
prisma: PrismaService,
mapper: UsernameMapper,
eventEmitter: EventEmitter2,
@Inject(MESSAGE_PUBLISHER)
protected readonly messagePublisher: MessagePublisherPort,
) {
super(
prisma.username,
prisma,
mapper,
eventEmitter,
new LoggerBase({
logger: new Logger(UsernameRepository.name),
domain: 'auth',
messagePublisher,
}),
);
}
}

View File

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

View File

@@ -0,0 +1,3 @@
import { ResponseBase } from '@mobicoop/ddd-library';
export class AuthenticationResponseDto extends ResponseBase {}

View File

@@ -0,0 +1,3 @@
import { ResponseBase } from '@mobicoop/ddd-library';
export class UsernameResponseDto extends ResponseBase {}

View File

@@ -0,0 +1,49 @@
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 { UsernameAlreadyExistsException } from '@modules/authentication/core/domain/authentication.errors';
import { AddUsernameRequestDto } from './dtos/add-username.request.dto';
import { AddUsernameCommand } from '@modules/authentication/core/application/commands/add-usernames/add-username.command';
@UsePipes(
new RpcValidationPipe({
whitelist: true,
forbidUnknownValues: false,
}),
)
@Controller()
export class AddUsernameGrpcController {
constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod('AuthenticationService', 'AddUsername')
async addUsername(data: AddUsernameRequestDto): Promise<IdResponse> {
try {
const aggregateID: AggregateID = await this.commandBus.execute(
new AddUsernameCommand({
userId: data.userId,
username: {
name: data.name,
type: data.type,
},
}),
);
return new IdResponse(aggregateID);
} catch (error: any) {
if (error instanceof UsernameAlreadyExistsException)
throw new RpcException({
code: RpcExceptionCode.ALREADY_EXISTS,
message: error.message,
});
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: error.message,
});
}
}
}

View File

@@ -0,0 +1,45 @@
syntax = "proto3";
package authentication;
service AuthenticationService {
rpc Validate(AuthenticationByUsernamePassword) returns (Id);
rpc Create(Authentication) returns (Id);
rpc AddUsername(Username) returns (Id);
rpc UpdatePassword(Password) returns (Id);
rpc UpdateUsername(Username) returns (Id);
rpc DeleteUsername(Username) returns (Id);
rpc Delete(UserId) returns (Empty);
}
message AuthenticationByUsernamePassword {
string username = 1;
string password = 2;
}
message Authentication {
string userId = 1;
repeated Username usernames = 2;
string password = 3;
}
message Password {
string userId = 1;
string password = 2;
}
message Username {
string userId = 1;
string name = 2;
string type = 3;
}
message Id {
string id = 1;
}
message UserId {
string userId = 1;
}
message Empty {}

View File

@@ -0,0 +1,43 @@
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 { CreateAuthenticationRequestDto } from './dtos/create-authentication.request.dto';
import { CreateAuthenticationCommand } from '@modules/authentication/core/application/commands/create-authentication/create-authentication.command';
import { AuthenticationAlreadyExistsException } from '@modules/authentication/core/domain/authentication.errors';
@UsePipes(
new RpcValidationPipe({
whitelist: true,
forbidUnknownValues: false,
}),
)
@Controller()
export class CreateAuthenticationGrpcController {
constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod('AuthenticationService', 'Create')
async create(data: CreateAuthenticationRequestDto): Promise<IdResponse> {
try {
const aggregateID: AggregateID = await this.commandBus.execute(
new CreateAuthenticationCommand(data),
);
return new IdResponse(aggregateID);
} catch (error: any) {
if (error instanceof AuthenticationAlreadyExistsException)
throw new RpcException({
code: RpcExceptionCode.ALREADY_EXISTS,
message: error.message,
});
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: error.message,
});
}
}
}

View File

@@ -0,0 +1,45 @@
import {
DatabaseErrorException,
NotFoundException,
RpcExceptionCode,
RpcValidationPipe,
} from '@mobicoop/ddd-library';
import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { DeleteAuthenticationCommand } from '@modules/authentication/core/application/commands/delete-authentication/delete-authentication.command';
import { DeleteAuthenticationRequestDto } from './dtos/delete-authentication.request.dto';
@UsePipes(
new RpcValidationPipe({
whitelist: true,
forbidUnknownValues: false,
}),
)
@Controller()
export class DeleteAuthenticationGrpcController {
constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod('AuthenticationService', 'Delete')
async delete(data: DeleteAuthenticationRequestDto): Promise<void> {
try {
await this.commandBus.execute(new DeleteAuthenticationCommand(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,8 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { UsernameDto } from './username.dto';
export class AddUsernameRequestDto extends UsernameDto {
@IsString()
@IsNotEmpty()
userId: string;
}

View File

@@ -0,0 +1,25 @@
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsNotEmpty,
IsString,
ValidateNested,
} from 'class-validator';
import { UsernameDto } from './username.dto';
export class CreateAuthenticationRequestDto {
@IsString()
@IsNotEmpty()
userId: string;
@Type(() => UsernameDto)
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
usernames: UsernameDto[];
@IsString()
@IsNotEmpty()
password: string;
}

View File

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

View File

@@ -0,0 +1,16 @@
import { Type } from '@modules/authentication/core/domain/username.types';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { IsValidUsername } from './validators/decorators/is-valid-username.decorator';
export class UsernameDto {
@IsString()
@IsNotEmpty()
@IsValidUsername({
message: 'Invalid username',
})
name: string;
@IsEnum(Type)
@IsNotEmpty()
type: Type;
}

View File

@@ -0,0 +1,32 @@
import { Type } from '@modules/authentication/core/domain/username.types';
import {
registerDecorator,
ValidationOptions,
ValidationArguments,
isEmail,
isPhoneNumber,
} from 'class-validator';
export function IsValidUsername(validationOptions?: ValidationOptions) {
return function (object: any, propertyName: string) {
registerDecorator({
name: 'isValidUsername',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: {
validate(value: any, args: ValidationArguments) {
const usernameType: Type = args.object['type'];
switch (usernameType) {
case Type.PHONE:
return isPhoneNumber(value);
case Type.EMAIL:
return isEmail(value);
default:
return false;
}
},
},
});
};
}

View File

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

View File

@@ -1,21 +0,0 @@
import { createMap, Mapper } from '@automapper/core';
import { AutomapperProfile, InjectMapper } from '@automapper/nestjs';
import { Injectable } from '@nestjs/common';
import { UsernamePresenter } from '../adapters/primaries/username.presenter';
import { AddUsernameRequest } from '../domain/dtos/add-username.request';
import { UpdateUsernameRequest } from '../domain/dtos/update-username.request';
import { Username } from '../domain/entities/username';
@Injectable()
export class UsernameProfile extends AutomapperProfile {
constructor(@InjectMapper() mapper: Mapper) {
super(mapper);
}
override get profile() {
return (mapper: any) => {
createMap(mapper, Username, UsernamePresenter);
createMap(mapper, UpdateUsernameRequest, AddUsernameRequest);
};
}
}

View File

@@ -1,9 +0,0 @@
export class ValidateAuthenticationQuery {
readonly username: string;
readonly password: string;
constructor(username: string, password: string) {
this.username = username;
this.password = password;
}
}

View File

@@ -1,162 +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 { AuthenticationRepository } from '../../adapters/secondaries/authentication.repository';
import { v4 } from 'uuid';
import * as bcrypt from 'bcrypt';
import { Authentication } from '../../domain/entities/authentication';
describe('AuthenticationRepository', () => {
let prismaService: PrismaService;
let authenticationRepository: AuthenticationRepository;
const createAuthentications = async (nbToCreate = 10) => {
for (let i = 0; i < nbToCreate; i++) {
await prismaService.auth.create({
data: {
uuid: v4(),
password: bcrypt.hashSync(`password-${i}`, 10),
},
});
}
};
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [DatabaseModule],
providers: [AuthenticationRepository, PrismaService],
}).compile();
prismaService = module.get<PrismaService>(PrismaService);
authenticationRepository = module.get<AuthenticationRepository>(
AuthenticationRepository,
);
});
afterAll(async () => {
await prismaService.$disconnect();
});
beforeEach(async () => {
await prismaService.auth.deleteMany();
});
describe('findAll', () => {
it('should return an empty data array', async () => {
const res = await authenticationRepository.findAll();
expect(res).toEqual({
data: [],
total: 0,
});
});
it('should return a data array with 8 auths', async () => {
await createAuthentications(8);
const auths = await authenticationRepository.findAll();
expect(auths.data.length).toBe(8);
expect(auths.total).toBe(8);
});
it('should return a data array limited to 10 authentications', async () => {
await createAuthentications(20);
const auths = await authenticationRepository.findAll();
expect(auths.data.length).toBe(10);
expect(auths.total).toBe(20);
});
});
describe('findOneByUuid', () => {
it('should return an authentication', async () => {
const authToFind = await prismaService.auth.create({
data: {
uuid: v4(),
password: bcrypt.hashSync(`password`, 10),
},
});
const auth = await authenticationRepository.findOneByUuid(
authToFind.uuid,
);
expect(auth.uuid).toBe(authToFind.uuid);
});
it('should return null', async () => {
const auth = await authenticationRepository.findOneByUuid(
'544572be-11fb-4244-8235-587221fc9104',
);
expect(auth).toBeNull();
});
});
describe('create', () => {
it('should create an authentication', async () => {
const beforeCount = await prismaService.auth.count();
const authenticationToCreate: Authentication = new Authentication();
authenticationToCreate.uuid = v4();
authenticationToCreate.password = bcrypt.hashSync(`password`, 10);
const authentication = await authenticationRepository.create(
authenticationToCreate,
);
const afterCount = await prismaService.auth.count();
expect(afterCount - beforeCount).toBe(1);
expect(authentication.uuid).toBeDefined();
});
});
describe('update', () => {
it('should update authentication password', async () => {
const authenticationToUpdate = await prismaService.auth.create({
data: {
uuid: v4(),
password: bcrypt.hashSync(`password`, 10),
},
});
const toUpdate: Authentication = new Authentication();
toUpdate.password = bcrypt.hashSync(`newPassword`, 10);
const updatedAuthentication = await authenticationRepository.update(
authenticationToUpdate.uuid,
toUpdate,
);
expect(updatedAuthentication.uuid).toBe(authenticationToUpdate.uuid);
});
it('should throw DatabaseException', async () => {
const toUpdate: Authentication = new Authentication();
toUpdate.password = bcrypt.hashSync(`newPassword`, 10);
await expect(
authenticationRepository.update(
'544572be-11fb-4244-8235-587221fc9104',
toUpdate,
),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('delete', () => {
it('should delete an authentication', async () => {
const authenticationToRemove = await prismaService.auth.create({
data: {
uuid: v4(),
password: bcrypt.hashSync(`password`, 10),
},
});
await authenticationRepository.delete(authenticationToRemove.uuid);
const count = await prismaService.auth.count();
expect(count).toBe(0);
});
it('should throw DatabaseException', async () => {
await expect(
authenticationRepository.delete('544572be-11fb-4244-8235-587221fc9104'),
).rejects.toBeInstanceOf(DatabaseException);
});
});
});

View File

@@ -1,282 +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 { v4 } from 'uuid';
import { Type } from '../../domain/dtos/type.enum';
import { UsernameRepository } from '../../adapters/secondaries/username.repository';
import { Username } from '../../domain/entities/username';
describe('UsernameRepository', () => {
let prismaService: PrismaService;
let usernameRepository: UsernameRepository;
const createUsernames = async (nbToCreate = 10) => {
for (let i = 0; i < nbToCreate; i++) {
await prismaService.username.create({
data: {
uuid: v4(),
username: `john.doe.${i}@email.com`,
type: Type.EMAIL,
},
});
}
};
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [DatabaseModule],
providers: [UsernameRepository, PrismaService],
}).compile();
prismaService = module.get<PrismaService>(PrismaService);
usernameRepository = module.get<UsernameRepository>(UsernameRepository);
});
afterAll(async () => {
await prismaService.$disconnect();
});
beforeEach(async () => {
await prismaService.username.deleteMany();
});
describe('findAll', () => {
it('should return an empty data array', async () => {
const res = await usernameRepository.findAll();
expect(res).toEqual({
data: [],
total: 0,
});
});
it('should return a data array with 8 usernames', async () => {
await createUsernames(8);
const usernames = await usernameRepository.findAll();
expect(usernames.data.length).toBe(8);
expect(usernames.total).toBe(8);
});
it('should return a data array limited to 10 usernames', async () => {
await createUsernames(20);
const usernames = await usernameRepository.findAll();
expect(usernames.data.length).toBe(10);
expect(usernames.total).toBe(20);
});
});
describe('findOne', () => {
it('should return a username with uuid and email', async () => {
const usernameToFind = await prismaService.username.create({
data: {
uuid: v4(),
username: 'john.doe@email.com',
type: Type.EMAIL,
},
});
const username = await usernameRepository.findOne({
username: 'john.doe@email.com',
type: Type.EMAIL,
});
expect(username.uuid).toBe(usernameToFind.uuid);
});
it('should return null', async () => {
const username = await usernameRepository.findOne({
username: 'jane.doe@email.com',
type: Type.EMAIL,
});
expect(username).toBeNull();
});
});
describe('create', () => {
it('should create a username with an email', async () => {
const beforeCount = await prismaService.username.count();
const usernameToCreate: Username = new Username();
usernameToCreate.uuid = v4();
usernameToCreate.username = 'john.doe@email.com';
usernameToCreate.type = Type.EMAIL;
const username = await usernameRepository.create(usernameToCreate);
const afterCount = await prismaService.username.count();
expect(afterCount - beforeCount).toBe(1);
expect(username.uuid).toBeDefined();
});
it('should create a username with a phone number', async () => {
const beforeCount = await prismaService.username.count();
const usernameToCreate: Username = new Username();
usernameToCreate.uuid = v4();
usernameToCreate.username = '+33611223344';
usernameToCreate.type = Type.PHONE;
const username = await usernameRepository.create(usernameToCreate);
const afterCount = await prismaService.username.count();
expect(afterCount - beforeCount).toBe(1);
expect(username.uuid).toBeDefined();
});
it('should create a username with an email for an existing uuid', async () => {
const beforeCount = await prismaService.username.count();
const uuid = v4();
const firstUsernameToCreate: Username = new Username();
firstUsernameToCreate.uuid = uuid;
firstUsernameToCreate.username = '+33611223344';
firstUsernameToCreate.type = Type.PHONE;
const firstUsername = await usernameRepository.create(
firstUsernameToCreate,
);
const secondUsernameToCreate: Username = new Username();
secondUsernameToCreate.uuid = uuid;
secondUsernameToCreate.username = 'john.doe@email.com';
secondUsernameToCreate.type = Type.EMAIL;
const secondUsername = await usernameRepository.create(
secondUsernameToCreate,
);
const afterCount = await prismaService.username.count();
expect(afterCount - beforeCount).toBe(2);
expect(firstUsername.uuid).toEqual(secondUsername.uuid);
});
it('should throw DatabaseException if username already exists for a given type', async () => {
const uuid = v4();
const firstUsernameToCreate: Username = new Username();
firstUsernameToCreate.uuid = uuid;
firstUsernameToCreate.username = 'john.doe@email.com';
firstUsernameToCreate.type = Type.EMAIL;
await usernameRepository.create(firstUsernameToCreate);
const secondUsernameToCreate: Username = new Username();
secondUsernameToCreate.uuid = uuid;
secondUsernameToCreate.username = 'jane.doe@email.com';
secondUsernameToCreate.type = Type.EMAIL;
await expect(
usernameRepository.create(secondUsernameToCreate),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('update', () => {
it('should update username email', async () => {
const usernameToUpdate = await prismaService.username.create({
data: {
uuid: v4(),
username: `john.doe@email.com`,
type: Type.EMAIL,
},
});
const toUpdate: Username = new Username();
toUpdate.username = 'jane.doe@email.com';
const updatedUsername = await usernameRepository.updateWhere(
{
uuid_type: {
uuid: usernameToUpdate.uuid,
type: usernameToUpdate.type,
},
},
{
username: toUpdate.username,
},
);
expect(updatedUsername.uuid).toBe(usernameToUpdate.uuid);
expect(updatedUsername.username).toBe('jane.doe@email.com');
});
it('should update username phone', async () => {
const usernameToUpdate = await prismaService.username.create({
data: {
uuid: v4(),
username: `+33611223344`,
type: Type.PHONE,
},
});
const toUpdate: Username = new Username();
toUpdate.username = '+33622334455';
const updatedUsername = await usernameRepository.updateWhere(
{
uuid_type: {
uuid: usernameToUpdate.uuid,
type: usernameToUpdate.type,
},
},
{
username: toUpdate.username,
},
);
expect(updatedUsername.uuid).toBe(usernameToUpdate.uuid);
expect(updatedUsername.username).toBe('+33622334455');
});
it('should throw DatabaseException if email not found', async () => {
const toUpdate: Username = new Username();
toUpdate.username = 'jane.doe@email.com';
await expect(
usernameRepository.updateWhere(
{
uuid_type: {
uuid: '544572be-11fb-4244-8235-587221fc9104',
type: Type.EMAIL,
},
},
{
username: toUpdate.username,
},
),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should throw DatabaseException if phone not found', async () => {
const toUpdate: Username = new Username();
toUpdate.username = '+33611223344';
await expect(
usernameRepository.updateWhere(
{
uuid_type: {
uuid: '544572be-11fb-4244-8235-587221fc9104',
type: Type.PHONE,
},
},
{
username: toUpdate.username,
},
),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('delete', () => {
it('should delete a username', async () => {
const usernameToRemove = await prismaService.username.create({
data: {
uuid: v4(),
username: `+33611223344`,
type: Type.PHONE,
},
});
await usernameRepository.deleteMany({ uuid: usernameToRemove.uuid });
const count = await prismaService.username.count();
expect(count).toBe(0);
});
it('should throw DatabaseException', async () => {
await expect(
usernameRepository.delete('544572be-11fb-4244-8235-587221fc9104'),
).rejects.toBeInstanceOf(DatabaseException);
});
});
});

View File

@@ -1,79 +0,0 @@
import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs';
import { Test, TestingModule } from '@nestjs/testing';
import { AuthenticationProfile } from '../../mappers/authentication.profile';
import { UsernameRepository } from '../../adapters/secondaries/username.repository';
import { Username } from '../../domain/entities/username';
import { Type } from '../../domain/dtos/type.enum';
import { AddUsernameRequest } from '../../domain/dtos/add-username.request';
import { AddUsernameCommand } from '../../commands/add-username.command';
import { AddUsernameUseCase } from '../../domain/usecases/add-username.usecase';
import { Messager } from '../../adapters/secondaries/messager';
const addUsernameRequest: AddUsernameRequest = {
uuid: 'bb281075-1b98-4456-89d6-c643d3044a91',
username: '0611223344',
type: Type.PHONE,
};
const addUsernameCommand: AddUsernameCommand = new AddUsernameCommand(
addUsernameRequest,
);
const mockUsernameRepository = {
create: jest
.fn()
.mockImplementationOnce(() => {
return Promise.resolve(addUsernameRequest);
})
.mockImplementation(() => {
throw new Error('Already exists');
}),
};
const mockMessager = {
publish: jest.fn().mockImplementation(),
};
describe('AddUsernameUseCase', () => {
let addUsernameUseCase: AddUsernameUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })],
providers: [
{
provide: UsernameRepository,
useValue: mockUsernameRepository,
},
{
provide: Messager,
useValue: mockMessager,
},
AddUsernameUseCase,
AuthenticationProfile,
],
}).compile();
addUsernameUseCase = module.get<AddUsernameUseCase>(AddUsernameUseCase);
});
it('should be defined', () => {
expect(addUsernameUseCase).toBeDefined();
});
describe('execute', () => {
it('should add a username for phone type', async () => {
const addedUsername: Username = await addUsernameUseCase.execute(
addUsernameCommand,
);
expect(addedUsername.username).toBe(addUsernameRequest.username);
expect(addedUsername.type).toBe(addUsernameRequest.type);
});
it('should throw an error if user already exists', async () => {
await expect(
addUsernameUseCase.execute(addUsernameCommand),
).rejects.toBeInstanceOf(Error);
});
});
});

View File

@@ -1,99 +0,0 @@
import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs';
import { Test, TestingModule } from '@nestjs/testing';
import { AuthenticationRepository } from '../../adapters/secondaries/authentication.repository';
import { CreateAuthenticationCommand } from '../../commands/create-authentication.command';
import { CreateAuthenticationRequest } from '../../domain/dtos/create-authentication.request';
import { Authentication } from '../../domain/entities/authentication';
import { CreateAuthenticationUseCase } from '../../domain/usecases/create-authentication.usecase';
import * as bcrypt from 'bcrypt';
import { UsernameRepository } from '../../adapters/secondaries/username.repository';
import { Type } from '../../domain/dtos/type.enum';
import { Messager } from '../../adapters/secondaries/messager';
const newAuthenticationRequest: CreateAuthenticationRequest =
new CreateAuthenticationRequest();
newAuthenticationRequest.uuid = 'bb281075-1b98-4456-89d6-c643d3044a91';
newAuthenticationRequest.username = 'john.doe@email.com';
newAuthenticationRequest.password = 'John123';
newAuthenticationRequest.type = Type.EMAIL;
const newAuthCommand: CreateAuthenticationCommand =
new CreateAuthenticationCommand(newAuthenticationRequest);
const mockAuthenticationRepository = {
create: jest
.fn()
.mockImplementationOnce(() => {
return Promise.resolve({
uuid: newAuthenticationRequest.uuid,
password: bcrypt.hashSync(newAuthenticationRequest.password, 10),
});
})
.mockImplementation(() => {
throw new Error('Already exists');
}),
};
const mockUsernameRepository = {
create: jest.fn().mockResolvedValue({
uuid: newAuthenticationRequest.uuid,
username: newAuthenticationRequest.username,
type: newAuthenticationRequest.type,
}),
};
const mockMessager = {
publish: jest.fn().mockImplementation(),
};
describe('CreateAuthenticationUseCase', () => {
let createAuthenticationUseCase: CreateAuthenticationUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })],
providers: [
{
provide: AuthenticationRepository,
useValue: mockAuthenticationRepository,
},
{
provide: UsernameRepository,
useValue: mockUsernameRepository,
},
{
provide: Messager,
useValue: mockMessager,
},
CreateAuthenticationUseCase,
],
}).compile();
createAuthenticationUseCase = module.get<CreateAuthenticationUseCase>(
CreateAuthenticationUseCase,
);
});
it('should be defined', () => {
expect(createAuthenticationUseCase).toBeDefined();
});
describe('execute', () => {
it('should create an authentication with an encrypted password', async () => {
const newAuthentication: Authentication =
await createAuthenticationUseCase.execute(newAuthCommand);
expect(
bcrypt.compareSync(
newAuthenticationRequest.password,
newAuthentication.password,
),
).toBeTruthy();
});
it('should throw an error if user already exists', async () => {
await expect(
createAuthenticationUseCase.execute(newAuthCommand),
).rejects.toBeInstanceOf(Error);
});
});
});

View File

@@ -1,102 +0,0 @@
import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs';
import { Test, TestingModule } from '@nestjs/testing';
import { AuthenticationRepository } from '../../adapters/secondaries/authentication.repository';
import { Messager } from '../../adapters/secondaries/messager';
import { UsernameRepository } from '../../adapters/secondaries/username.repository';
import { DeleteAuthenticationCommand } from '../../commands/delete-authentication.command';
import { DeleteAuthenticationRequest } from '../../domain/dtos/delete-authentication.request';
import { Type } from '../../domain/dtos/type.enum';
import { DeleteAuthenticationUseCase } from '../../domain/usecases/delete-authentication.usecase';
const usernames = {
data: [
{
uuid: 'cf76af29-f75d-4f6e-bb40-4ecbcfa3356e',
username: 'john.doe@email.com',
type: Type.EMAIL,
},
{
uuid: 'cf76af29-f75d-4f6e-bb40-4ecbcfa3356e',
username: '0611223344',
type: Type.PHONE,
},
],
total: 2,
};
const deleteAuthenticationRequest: DeleteAuthenticationRequest =
new DeleteAuthenticationRequest();
deleteAuthenticationRequest.uuid = 'bb281075-1b98-4456-89d6-c643d3044a91';
const deleteAuthenticationCommand: DeleteAuthenticationCommand =
new DeleteAuthenticationCommand(deleteAuthenticationRequest);
const mockAuthenticationRepository = {
delete: jest
.fn()
.mockResolvedValueOnce(undefined)
.mockImplementation(() => {
throw new Error('Error');
}),
};
const mockUsernameRepository = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
findAll: jest.fn().mockImplementation((page, perPage, query) => {
return Promise.resolve(usernames);
}),
delete: jest.fn().mockResolvedValue(undefined),
deleteMany: jest.fn().mockResolvedValue(undefined),
};
const mockMessager = {
publish: jest.fn().mockImplementation(),
};
describe('DeleteAuthenticationUseCase', () => {
let deleteAuthenticationUseCase: DeleteAuthenticationUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })],
providers: [
{
provide: AuthenticationRepository,
useValue: mockAuthenticationRepository,
},
{
provide: UsernameRepository,
useValue: mockUsernameRepository,
},
{
provide: Messager,
useValue: mockMessager,
},
DeleteAuthenticationUseCase,
],
}).compile();
deleteAuthenticationUseCase = module.get<DeleteAuthenticationUseCase>(
DeleteAuthenticationUseCase,
);
});
it('should be defined', () => {
expect(deleteAuthenticationUseCase).toBeDefined();
});
describe('execute', () => {
it('should delete an authentication and its usernames', async () => {
const deletedAuthentication = await deleteAuthenticationUseCase.execute(
deleteAuthenticationCommand,
);
expect(deletedAuthentication).toBe(undefined);
});
it('should throw an error if authentication does not exist', async () => {
await expect(
deleteAuthenticationUseCase.execute(deleteAuthenticationCommand),
).rejects.toBeInstanceOf(Error);
});
});
});

View File

@@ -1,115 +0,0 @@
import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs';
import { UnauthorizedException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { Messager } from '../../adapters/secondaries/messager';
import { UsernameRepository } from '../../adapters/secondaries/username.repository';
import { DeleteUsernameCommand } from '../../commands/delete-username.command';
import { DeleteUsernameRequest } from '../../domain/dtos/delete-username.request';
import { Type } from '../../domain/dtos/type.enum';
import { DeleteUsernameUseCase } from '../../domain/usecases/delete-username.usecase';
const usernamesEmail = {
data: [
{
uuid: 'cf76af29-f75d-4f6e-bb40-4ecbcfa3356e',
username: 'john.doe@email.com',
type: Type.EMAIL,
},
{
uuid: 'cf76af29-f75d-4f6e-bb40-4ecbcfa3356e',
username: '0611223344',
type: Type.PHONE,
},
],
total: 2,
};
const usernamesPhone = {
data: [
{
uuid: 'a7fa221f-dd77-481c-bb77-ae89da662c87',
username: '0611223344',
type: Type.PHONE,
},
],
total: 1,
};
const deleteUsernameEmailRequest: DeleteUsernameRequest =
new DeleteUsernameRequest();
deleteUsernameEmailRequest.username = 'john.doe@email.com';
const deleteUsernamePhoneRequest: DeleteUsernameRequest =
new DeleteUsernameRequest();
deleteUsernamePhoneRequest.username = '0611223344';
const deleteUsernameEmailCommand: DeleteUsernameCommand =
new DeleteUsernameCommand(deleteUsernameEmailRequest);
const deleteUsernamePhoneCommand: DeleteUsernameCommand =
new DeleteUsernameCommand(deleteUsernamePhoneRequest);
const mockUsernameRepository = {
findOne: jest.fn().mockImplementation((where) => {
if (where.username == 'john.doe@email.com') {
return { uuid: 'cf76af29-f75d-4f6e-bb40-4ecbcfa3356e' };
}
return { uuid: 'a7fa221f-dd77-481c-bb77-ae89da662c87' };
}),
findAll: jest.fn().mockImplementation((page, perPage, query) => {
if (query.uuid == 'cf76af29-f75d-4f6e-bb40-4ecbcfa3356e') {
return Promise.resolve(usernamesEmail);
}
return Promise.resolve(usernamesPhone);
}),
deleteMany: jest.fn().mockResolvedValue(undefined),
};
const mockMessager = {
publish: jest.fn().mockImplementation(),
};
describe('DeleteUsernameUseCase', () => {
let deleteUsernameUseCase: DeleteUsernameUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })],
providers: [
{
provide: UsernameRepository,
useValue: mockUsernameRepository,
},
{
provide: Messager,
useValue: mockMessager,
},
DeleteUsernameUseCase,
],
}).compile();
deleteUsernameUseCase = module.get<DeleteUsernameUseCase>(
DeleteUsernameUseCase,
);
});
it('should be defined', () => {
expect(deleteUsernameUseCase).toBeDefined();
});
describe('execute', () => {
it('should delete a username', async () => {
const deletedEmailUsername = await deleteUsernameUseCase.execute(
deleteUsernameEmailCommand,
);
expect(deletedEmailUsername).toBe(undefined);
});
it('should throw an exception if auth has only one username', async () => {
await expect(
deleteUsernameUseCase.execute(deleteUsernamePhoneCommand),
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
});

View File

@@ -0,0 +1,89 @@
import { IdResponse } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { UsernameAlreadyExistsException } from '@modules/authentication/core/domain/authentication.errors';
import { Type } from '@modules/authentication/core/domain/username.types';
import { AddUsernameGrpcController } from '@modules/authentication/interface/grpc-controllers/add-username.grpc.controller';
import { AddUsernameRequestDto } from '@modules/authentication/interface/grpc-controllers/dtos/add-username.request.dto';
import { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
const addUsernameRequest: AddUsernameRequestDto = {
userId: '78153e03-4861-4f58-a705-88526efee53b',
name: 'john.doe@email.com',
type: Type.EMAIL,
};
const mockCommandBus = {
execute: jest
.fn()
.mockImplementationOnce(() => 'john.doe@email.com')
.mockImplementationOnce(() => {
throw new UsernameAlreadyExistsException();
})
.mockImplementationOnce(() => {
throw new Error();
}),
};
describe('Add Username Grpc Controller', () => {
let addUsernameGrpcController: AddUsernameGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
AddUsernameGrpcController,
],
}).compile();
addUsernameGrpcController = module.get<AddUsernameGrpcController>(
AddUsernameGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(addUsernameGrpcController).toBeDefined();
});
it('should add a new username', async () => {
jest.spyOn(mockCommandBus, 'execute');
const result: IdResponse = await addUsernameGrpcController.addUsername(
addUsernameRequest,
);
expect(result).toBeInstanceOf(IdResponse);
expect(result.id).toBe('john.doe@email.com');
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if username already exists', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await addUsernameGrpcController.addUsername(addUsernameRequest);
} 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 addUsernameGrpcController.addUsername(addUsernameRequest);
} 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 { IdResponse } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { AuthenticationAlreadyExistsException } from '@modules/authentication/core/domain/authentication.errors';
import { Type } from '@modules/authentication/core/domain/username.types';
import { CreateAuthenticationGrpcController } from '@modules/authentication/interface/grpc-controllers/create-authentication.grpc.controller';
import { CreateAuthenticationRequestDto } from '@modules/authentication/interface/grpc-controllers/dtos/create-authentication.request.dto';
import { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
const createAuthenticationRequest: CreateAuthenticationRequestDto = {
userId: '78153e03-4861-4f58-a705-88526efee53b',
password: 'John123',
usernames: [
{
name: 'john.doe@email.com',
type: Type.EMAIL,
},
],
};
const mockCommandBus = {
execute: jest
.fn()
.mockImplementationOnce(() => '78153e03-4861-4f58-a705-88526efee53b')
.mockImplementationOnce(() => {
throw new AuthenticationAlreadyExistsException();
})
.mockImplementationOnce(() => {
throw new Error();
}),
};
describe('Create Authentication Grpc Controller', () => {
let createAuthenticationGrpcController: CreateAuthenticationGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
CreateAuthenticationGrpcController,
],
}).compile();
createAuthenticationGrpcController =
module.get<CreateAuthenticationGrpcController>(
CreateAuthenticationGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(createAuthenticationGrpcController).toBeDefined();
});
it('should create a new authentication', async () => {
jest.spyOn(mockCommandBus, 'execute');
const result: IdResponse = await createAuthenticationGrpcController.create(
createAuthenticationRequest,
);
expect(result).toBeInstanceOf(IdResponse);
expect(result.id).toBe('78153e03-4861-4f58-a705-88526efee53b');
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if authentication already exists', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await createAuthenticationGrpcController.create(
createAuthenticationRequest,
);
} 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 createAuthenticationGrpcController.create(
createAuthenticationRequest,
);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,108 @@
import {
DatabaseErrorException,
NotFoundException,
} from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { DeleteAuthenticationGrpcController } from '@modules/authentication/interface/grpc-controllers/delete-authentication.grpc.controller';
import { DeleteAuthenticationRequestDto } from '@modules/authentication/interface/grpc-controllers/dtos/delete-authentication.request.dto';
import { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
const deleteAuthenticationRequest: DeleteAuthenticationRequestDto = {
userId: '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 Authentication Grpc Controller', () => {
let deleteAuthenticationGrpcController: DeleteAuthenticationGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
DeleteAuthenticationGrpcController,
],
}).compile();
deleteAuthenticationGrpcController =
module.get<DeleteAuthenticationGrpcController>(
DeleteAuthenticationGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(deleteAuthenticationGrpcController).toBeDefined();
});
it('should create a new authentication', async () => {
jest.spyOn(mockCommandBus, 'execute');
await deleteAuthenticationGrpcController.delete(
deleteAuthenticationRequest,
);
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if authentication does not exist', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await deleteAuthenticationGrpcController.delete(
deleteAuthenticationRequest,
);
} 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 deleteAuthenticationGrpcController.delete(
deleteAuthenticationRequest,
);
} 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 deleteAuthenticationGrpcController.delete(
deleteAuthenticationRequest,
);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,57 @@
import { Type } from '@modules/authentication/core/domain/username.types';
import { IsValidUsername } from '@modules/authentication/interface/grpc-controllers/dtos/validators/decorators/is-valid-username.decorator';
import { Validator } from 'class-validator';
describe('Username decorator', () => {
class MyClass {
@IsValidUsername({
message: 'Invalid username',
})
name: string;
type: Type;
}
it('should return a property decorator has a function', () => {
const isValidUsername = IsValidUsername();
expect(typeof isValidUsername).toBe('function');
});
it('should validate a valid phone username', async () => {
const myClassInstance = new MyClass();
myClassInstance.name = '+33611223344';
myClassInstance.type = Type.PHONE;
const validator = new Validator();
const validation = await validator.validate(myClassInstance);
expect(validation.length).toBe(0);
});
it('should validate a valid email username', async () => {
const myClassInstance = new MyClass();
myClassInstance.name = 'john.doe@email.com';
myClassInstance.type = Type.EMAIL;
const validator = new Validator();
const validation = await validator.validate(myClassInstance);
expect(validation.length).toBe(0);
});
it('should not validate an invalid phone username', async () => {
const myClassInstance = new MyClass();
myClassInstance.name = '11223344';
myClassInstance.type = Type.PHONE;
const validator = new Validator();
const validation = await validator.validate(myClassInstance);
expect(validation.length).toBe(1);
});
it('should not validate an invalid email username', async () => {
const myClassInstance = new MyClass();
myClassInstance.name = 'john.doe.email.com';
myClassInstance.type = Type.EMAIL;
const validator = new Validator();
const validation = await validator.validate(myClassInstance);
expect(validation.length).toBe(1);
});
it('should not validate if type is not set', async () => {
const myClassInstance = new MyClass();
myClassInstance.name = 'john.doe@email.com';
const validator = new Validator();
const validation = await validator.validate(myClassInstance);
expect(validation.length).toBe(1);
});
});

View File

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

View File

@@ -1,81 +0,0 @@
import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs';
import { Test, TestingModule } from '@nestjs/testing';
import { AuthenticationRepository } from '../../adapters/secondaries/authentication.repository';
import { Authentication } from '../../domain/entities/authentication';
import * as bcrypt from 'bcrypt';
import { UpdatePasswordRequest } from '../../domain/dtos/update-password.request';
import { UpdatePasswordCommand } from '../../commands/update-password.command';
import { UpdatePasswordUseCase } from '../../domain/usecases/update-password.usecase';
import { Messager } from '../../adapters/secondaries/messager';
const updatePasswordRequest: UpdatePasswordRequest =
new UpdatePasswordRequest();
updatePasswordRequest.uuid = 'bb281075-1b98-4456-89d6-c643d3044a91';
updatePasswordRequest.password = 'John123';
const updatePasswordCommand: UpdatePasswordCommand = new UpdatePasswordCommand(
updatePasswordRequest,
);
const mockAuthenticationRepository = {
update: jest
.fn()
.mockResolvedValueOnce({
uuid: updatePasswordRequest.uuid,
password: bcrypt.hashSync(updatePasswordRequest.password, 10),
})
.mockImplementation(() => {
throw new Error('Error');
}),
};
const mockMessager = {
publish: jest.fn().mockImplementation(),
};
describe('UpdatePasswordUseCase', () => {
let updatePasswordUseCase: UpdatePasswordUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })],
providers: [
{
provide: AuthenticationRepository,
useValue: mockAuthenticationRepository,
},
{
provide: Messager,
useValue: mockMessager,
},
UpdatePasswordUseCase,
],
}).compile();
updatePasswordUseCase = module.get<UpdatePasswordUseCase>(
UpdatePasswordUseCase,
);
});
it('should be defined', () => {
expect(updatePasswordUseCase).toBeDefined();
});
describe('execute', () => {
it('should update an auth with an new encrypted password', async () => {
const newAuth: Authentication = await updatePasswordUseCase.execute(
updatePasswordCommand,
);
expect(
bcrypt.compareSync(updatePasswordRequest.password, newAuth.password),
).toBeTruthy();
});
it('should throw an error if auth does not exist', async () => {
await expect(
updatePasswordUseCase.execute(updatePasswordCommand),
).rejects.toBeInstanceOf(Error);
});
});
});

View File

@@ -1,151 +0,0 @@
import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs';
import { Test, TestingModule } from '@nestjs/testing';
import { UsernameRepository } from '../../adapters/secondaries/username.repository';
import { Username } from '../../domain/entities/username';
import { UpdateUsernameRequest } from '../../domain/dtos/update-username.request';
import { UpdateUsernameCommand } from '../../commands/update-username.command';
import { Type } from '../../domain/dtos/type.enum';
import { UpdateUsernameUseCase } from '../../domain/usecases/update-username.usecase';
import { CommandBus } from '@nestjs/cqrs';
import { UsernameProfile } from '../../mappers/username.profile';
import { BadRequestException } from '@nestjs/common';
import { Messager } from '../../adapters/secondaries/messager';
const existingUsername = {
uuid: 'bb281075-1b98-4456-89d6-c643d3044a91',
username: 'john.doe@email.com',
type: Type.EMAIL,
};
const newUsernameRequest: UpdateUsernameRequest = new UpdateUsernameRequest();
newUsernameRequest.uuid = 'bb281075-1b98-4456-89d6-c643d3044a90';
newUsernameRequest.username = '+33611223344';
newUsernameRequest.type = Type.PHONE;
const updateUsernameRequest: UpdateUsernameRequest =
new UpdateUsernameRequest();
updateUsernameRequest.uuid = 'bb281075-1b98-4456-89d6-c643d3044a91';
updateUsernameRequest.username = 'johnny.doe@email.com';
updateUsernameRequest.type = Type.EMAIL;
const unknownUsernameRequest: UpdateUsernameRequest =
new UpdateUsernameRequest();
unknownUsernameRequest.uuid = 'bb281075-1b98-4456-89d6-c643d3044a92';
unknownUsernameRequest.username = 'unknown@email.com';
unknownUsernameRequest.type = Type.EMAIL;
const invalidUpdateUsernameRequest: UpdateUsernameRequest =
new UpdateUsernameRequest();
invalidUpdateUsernameRequest.uuid = 'bb281075-1b98-4456-89d6-c643d3044a93';
invalidUpdateUsernameRequest.username = '';
invalidUpdateUsernameRequest.type = Type.EMAIL;
const newUsernameCommand: UpdateUsernameCommand = new UpdateUsernameCommand(
newUsernameRequest,
);
const updateUsernameCommand: UpdateUsernameCommand = new UpdateUsernameCommand(
updateUsernameRequest,
);
const invalidUpdateUsernameCommand: UpdateUsernameCommand =
new UpdateUsernameCommand(invalidUpdateUsernameRequest);
const unknownUpdateUsernameCommand: UpdateUsernameCommand =
new UpdateUsernameCommand(unknownUsernameRequest);
const mockUsernameRepository = {
findOne: jest.fn().mockImplementation((request) => {
if (request.uuid == 'bb281075-1b98-4456-89d6-c643d3044a90') {
return Promise.resolve(null);
}
return Promise.resolve(existingUsername);
}),
updateWhere: jest.fn().mockImplementation((request) => {
if (request.uuid_type.uuid == 'bb281075-1b98-4456-89d6-c643d3044a90') {
return Promise.resolve(newUsernameRequest);
}
if (request.uuid_type.uuid == 'bb281075-1b98-4456-89d6-c643d3044a91') {
return Promise.resolve(updateUsernameRequest);
}
if (request.uuid_type.uuid == 'bb281075-1b98-4456-89d6-c643d3044a92') {
throw new Error('Error');
}
return Promise.resolve(invalidUpdateUsernameRequest);
}),
};
const mockAddUsernameCommand = {
execute: jest.fn().mockResolvedValue(newUsernameRequest),
};
const mockMessager = {
publish: jest.fn().mockImplementation(),
};
describe('UpdateUsernameUseCase', () => {
let updateUsernameUseCase: UpdateUsernameUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })],
providers: [
{
provide: UsernameRepository,
useValue: mockUsernameRepository,
},
{
provide: CommandBus,
useValue: mockAddUsernameCommand,
},
{
provide: Messager,
useValue: mockMessager,
},
UpdateUsernameUseCase,
UsernameProfile,
],
}).compile();
updateUsernameUseCase = module.get<UpdateUsernameUseCase>(
UpdateUsernameUseCase,
);
});
it('should be defined', () => {
expect(updateUsernameUseCase).toBeDefined();
});
describe('execute', () => {
it('should create a new username', async () => {
const newUsername: Username = await updateUsernameUseCase.execute(
newUsernameCommand,
);
expect(newUsername.username).toBe(newUsernameRequest.username);
expect(newUsername.type).toBe(newUsernameRequest.type);
});
it('should update a username for email type', async () => {
const updatedUsername: Username = await updateUsernameUseCase.execute(
updateUsernameCommand,
);
expect(updatedUsername.username).toBe(updateUsernameRequest.username);
expect(updatedUsername.type).toBe(updateUsernameRequest.type);
});
it('should throw an error if username does not exist', async () => {
await expect(
updateUsernameUseCase.execute(unknownUpdateUsernameCommand),
).rejects.toBeInstanceOf(Error);
});
it('should throw an exception if username is invalid', async () => {
await expect(
updateUsernameUseCase.execute(invalidUpdateUsernameCommand),
).rejects.toBeInstanceOf(BadRequestException);
});
});
});

View File

@@ -1,107 +0,0 @@
import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs';
import { Test, TestingModule } from '@nestjs/testing';
import { AuthenticationRepository } from '../../adapters/secondaries/authentication.repository';
import { Authentication } from '../../domain/entities/authentication';
import * as bcrypt from 'bcrypt';
import { ValidateAuthenticationUseCase } from '../../domain/usecases/validate-authentication.usecase';
import { ValidateAuthenticationQuery } from '../../queries/validate-authentication.query';
import { UsernameRepository } from '../../adapters/secondaries/username.repository';
import { Type } from '../../domain/dtos/type.enum';
import { NotFoundException, UnauthorizedException } from '@nestjs/common';
import { DatabaseException } from '../../../database/exceptions/database.exception';
import { ValidateAuthenticationRequest } from '../../domain/dtos/validate-authentication.request';
const mockAuthenticationRepository = {
findOne: jest
.fn()
.mockImplementationOnce(() => ({
uuid: 'bb281075-1b98-4456-89d6-c643d3044a91',
password: bcrypt.hashSync('John123', 10),
}))
.mockImplementationOnce(() => ({
uuid: 'bb281075-1b98-4456-89d6-c643d3044a91',
password: bcrypt.hashSync('John123', 10),
})),
};
const mockUsernameRepository = {
findOne: jest
.fn()
.mockImplementationOnce(() => ({
uuid: 'bb281075-1b98-4456-89d6-c643d3044a91',
username: 'john.doe@email.com',
type: Type.EMAIL,
}))
.mockImplementationOnce(() => {
throw new DatabaseException();
})
.mockImplementationOnce(() => ({
uuid: 'bb281075-1b98-4456-89d6-c643d3044a91',
username: 'john.doe@email.com',
type: Type.EMAIL,
})),
};
describe('ValidateAuthenticationUseCase', () => {
let validateAuthenticationUseCase: ValidateAuthenticationUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })],
providers: [
{
provide: AuthenticationRepository,
useValue: mockAuthenticationRepository,
},
{
provide: UsernameRepository,
useValue: mockUsernameRepository,
},
ValidateAuthenticationUseCase,
],
}).compile();
validateAuthenticationUseCase = module.get<ValidateAuthenticationUseCase>(
ValidateAuthenticationUseCase,
);
});
it('should be defined', () => {
expect(validateAuthenticationUseCase).toBeDefined();
});
describe('execute', () => {
it('should validate an authentication and returns entity object', async () => {
const validateAuthenticationRequest: ValidateAuthenticationRequest =
new ValidateAuthenticationRequest();
validateAuthenticationRequest.username = 'john.doe@email.com';
validateAuthenticationRequest.password = 'John123';
const authentication: Authentication =
await validateAuthenticationUseCase.execute(
new ValidateAuthenticationQuery(
validateAuthenticationRequest.username,
validateAuthenticationRequest.password,
),
);
expect(authentication.uuid).toBe('bb281075-1b98-4456-89d6-c643d3044a91');
});
it('should not validate an authentication with unknown username and returns not found exception', async () => {
await expect(
validateAuthenticationUseCase.execute(
new ValidateAuthenticationQuery('jane.doe@email.com', 'Jane123'),
),
).rejects.toBeInstanceOf(NotFoundException);
});
it('should not validate an authentication with wrong password and returns unauthorized exception', async () => {
await expect(
validateAuthenticationUseCase.execute(
new ValidateAuthenticationQuery('john.doe@email.com', 'John1234'),
),
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
});

View File

@@ -0,0 +1,49 @@
import { Mapper } from '@mobicoop/ddd-library';
import { Injectable } from '@nestjs/common';
import { Type } from './core/domain/username.types';
import { UsernameEntity } from './core/domain/username.entity';
import { UsernameModel } from './infrastructure/username.repository';
import { UsernameResponseDto } from './interface/dtos/username.response.dto';
/**
* Mapper constructs objects that are used in different layers:
* Record is an object that is stored in a database,
* Entity is an object that is used in application domain layer,
* and a ResponseDTO is an object returned to a user (usually as json).
*/
@Injectable()
export class UsernameMapper
implements
Mapper<UsernameEntity, UsernameModel, UsernameModel, UsernameResponseDto>
{
toPersistence = (entity: UsernameEntity): UsernameModel => {
const copy = entity.getProps();
const record: UsernameModel = {
authUuid: copy.userId,
username: copy.name,
type: copy.type,
createdAt: copy.createdAt,
updatedAt: copy.updatedAt,
};
return record;
};
toDomain = (record: UsernameModel): UsernameEntity => {
const entity = new UsernameEntity({
id: record.authUuid,
createdAt: new Date(record.createdAt),
updatedAt: new Date(record.updatedAt),
props: {
name: record.username,
type: Type[record.type],
},
});
return entity;
};
toResponse = (entity: UsernameEntity): UsernameResponseDto => {
const response = new UsernameResponseDto(entity);
return response;
};
}