update username

This commit is contained in:
sbriat 2023-07-07 17:50:49 +02:00
parent 28c6ca0f63
commit 805a7fe24d
14 changed files with 372 additions and 3 deletions

View File

@ -19,11 +19,14 @@ import { AddUsernameGrpcController } from './interface/grpc-controllers/add-user
import { AddUsernameService } from './core/application/commands/add-username/add-username.service';
import { DeleteUsernameGrpcController } from './interface/grpc-controllers/delete-username.grpc.controller';
import { DeleteUsernameService } from './core/application/commands/delete-username/delete-username.service';
import { UpdateUsernameGrpcController } from './interface/grpc-controllers/update-username.grpc.controller';
import { UpdateUsernameService } from './core/application/commands/update-username/update-username.service';
const grpcControllers = [
CreateAuthenticationGrpcController,
DeleteAuthenticationGrpcController,
AddUsernameGrpcController,
UpdateUsernameGrpcController,
DeleteUsernameGrpcController,
];
@ -31,6 +34,7 @@ const commandHandlers: Provider[] = [
CreateAuthenticationService,
DeleteAuthenticationService,
AddUsernameService,
UpdateUsernameService,
DeleteUsernameService,
];

View File

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

View File

@ -0,0 +1,46 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import {
AggregateID,
ConflictException,
UniqueConstraintException,
} from '@mobicoop/ddd-library';
import { USERNAME_REPOSITORY } from '@modules/authentication/authentication.di-tokens';
import { UsernameAlreadyExistsException } from '@modules/authentication/core/domain/authentication.errors';
import { UpdateUsernameCommand } from './update-username.command';
import { UsernameRepositoryPort } from '../../ports/username.repository.port';
import { UsernameEntity } from '@modules/authentication/core/domain/username.entity';
@CommandHandler(UpdateUsernameCommand)
export class UpdateUsernameService implements ICommandHandler {
constructor(
@Inject(USERNAME_REPOSITORY)
private readonly usernameRepository: UsernameRepositoryPort,
) {}
async execute(command: UpdateUsernameCommand): Promise<AggregateID> {
try {
const username: UsernameEntity = await this.usernameRepository.findByType(
command.userId,
command.username.type,
);
const oldName: string = username.id;
username.update({
name: command.username.name,
});
await this.usernameRepository.updateUsername(oldName, username);
return username.getProps().name;
} 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

@ -1,4 +1,8 @@
import { RepositoryPort } from '@mobicoop/ddd-library';
import { UsernameEntity } from '../../domain/username.entity';
import { Type } from '../../domain/username.types';
export type UsernameRepositoryPort = RepositoryPort<UsernameEntity>;
export type UsernameRepositoryPort = RepositoryPort<UsernameEntity> & {
findByType(userId: string, type: Type): Promise<UsernameEntity>;
updateUsername(oldName: string, entity: UsernameEntity): Promise<void>;
};

View File

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

View File

@ -1,7 +1,12 @@
import { AggregateID, AggregateRoot } from '@mobicoop/ddd-library';
import { CreateUsernameProps, UsernameProps } from './username.types';
import {
CreateUsernameProps,
UpdateUsernameProps,
UsernameProps,
} from './username.types';
import { UsernameAddedDomainEvent } from './events/username-added.domain-event';
import { UsernameDeletedDomainEvent } from './events/username-deleted.domain-event';
import { UsernameUpdatedDomainEvent } from './events/username-updated.domain-event';
export class UsernameEntity extends AggregateRoot<UsernameProps> {
protected readonly _id: AggregateID;
@ -24,6 +29,15 @@ export class UsernameEntity extends AggregateRoot<UsernameProps> {
return username;
};
update(props: UpdateUsernameProps): void {
this.props.name = props.name;
this.addEvent(
new UsernameUpdatedDomainEvent({
aggregateId: props.name,
}),
);
}
delete(): void {
this.addEvent(
new UsernameDeletedDomainEvent({

View File

@ -10,6 +10,10 @@ export interface CreateUsernameProps {
type: Type;
}
export interface UpdateUsernameProps {
name: string;
}
export enum Type {
EMAIL = 'EMAIL',
PHONE = 'PHONE',

View File

@ -10,6 +10,7 @@ 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';
import { Type } from '../core/domain/username.types';
export type UsernameModel = {
username: string;
@ -46,4 +47,21 @@ export class UsernameRepository
}),
);
}
findByType = async (userId: string, type: Type): Promise<UsernameEntity> =>
this.findOne({
authUuid: userId,
type,
});
updateUsername = async (
oldName: string,
entity: UsernameEntity,
): Promise<void> =>
this.updateWhere(
{
username: oldName,
},
entity,
);
}

View File

@ -0,0 +1,8 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { UsernameDto } from './username.dto';
export class UpdateUsernameRequestDto extends UsernameDto {
@IsString()
@IsNotEmpty()
userId: string;
}

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 { UpdateUsernameRequestDto } from './dtos/update-username.request.dto';
import { UpdateUsernameCommand } from '@modules/authentication/core/application/commands/update-username/update-username.command';
@UsePipes(
new RpcValidationPipe({
whitelist: true,
forbidUnknownValues: false,
}),
)
@Controller()
export class UpdateUsernameGrpcController {
constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod('AuthenticationService', 'UpdateUsername')
async updateUsername(data: UpdateUsernameRequestDto): Promise<IdResponse> {
try {
const aggregateID: AggregateID = await this.commandBus.execute(
new UpdateUsernameCommand({
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,97 @@
import {
AggregateID,
ConflictException,
UniqueConstraintException,
} from '@mobicoop/ddd-library';
import { USERNAME_REPOSITORY } from '@modules/authentication/authentication.di-tokens';
import { UpdateUsernameCommand } from '@modules/authentication/core/application/commands/update-username/update-username.command';
import { UpdateUsernameService } from '@modules/authentication/core/application/commands/update-username/update-username.service';
import { UsernameAlreadyExistsException } from '@modules/authentication/core/domain/authentication.errors';
import { Type } from '@modules/authentication/core/domain/username.types';
import { UpdateUsernameRequestDto } from '@modules/authentication/interface/grpc-controllers/dtos/update-username.request.dto';
import { Test, TestingModule } from '@nestjs/testing';
const updateUsernameRequest: UpdateUsernameRequestDto = {
userId: '165192d4-398a-4469-a16b-98c02cc6f531',
name: 'new-john.doe@email.com',
type: Type.EMAIL,
};
const mockUsernameRepository = {
findByType: jest.fn().mockImplementation(() => ({
id: 'john.doe@email.com',
update: jest.fn(),
getProps: jest.fn().mockImplementation(() => ({
userId: '165192d4-398a-4469-a16b-98c02cc6f531',
name: 'new-john.doe@email.com',
type: Type.EMAIL,
})),
})),
updateUsername: jest
.fn()
.mockImplementationOnce(() => ({}))
.mockImplementationOnce(() => {
throw new ConflictException('already exists');
})
.mockImplementationOnce(() => {
throw new UniqueConstraintException('username');
})
.mockImplementationOnce(() => {
throw new Error();
}),
};
describe('Update Username Service', () => {
let updateUsernameService: UpdateUsernameService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: USERNAME_REPOSITORY,
useValue: mockUsernameRepository,
},
UpdateUsernameService,
],
}).compile();
updateUsernameService = module.get<UpdateUsernameService>(
UpdateUsernameService,
);
});
it('should be defined', () => {
expect(updateUsernameService).toBeDefined();
});
describe('execution', () => {
const updateUsernameCommand = new UpdateUsernameCommand({
userId: updateUsernameRequest.userId,
username: {
name: updateUsernameRequest.name,
type: updateUsernameRequest.type,
},
});
it('should update a username', async () => {
const result: AggregateID = await updateUsernameService.execute(
updateUsernameCommand,
);
expect(result).toBe('new-john.doe@email.com');
});
it('should throw a dedicated exception if username already exists for this type', async () => {
await expect(
updateUsernameService.execute(updateUsernameCommand),
).rejects.toBeInstanceOf(UsernameAlreadyExistsException);
});
it('should throw a dedicated exception if username already exists for this name', async () => {
await expect(
updateUsernameService.execute(updateUsernameCommand),
).rejects.toBeInstanceOf(UsernameAlreadyExistsException);
});
it('should throw an error if something bad happens', async () => {
await expect(
updateUsernameService.execute(updateUsernameCommand),
).rejects.toBeInstanceOf(Error);
});
});
});

View File

@ -1,4 +1,5 @@
import { UsernameDeletedDomainEvent } from '@modules/authentication/core/domain/events/username-deleted.domain-event';
import { UsernameUpdatedDomainEvent } from '@modules/authentication/core/domain/events/username-updated.domain-event';
import { UsernameEntity } from '@modules/authentication/core/domain/username.entity';
import {
CreateUsernameProps,
@ -20,6 +21,21 @@ describe('Username entity create', () => {
expect(usernameEntity.domainEvents.length).toBe(1);
});
});
describe('Username entity update', () => {
it('should update a username entity', async () => {
const usernameEntity: UsernameEntity = await UsernameEntity.create(
createUsernameProps,
);
usernameEntity.update({
name: 'new-john.doe@email.com',
});
expect(usernameEntity.getProps().name).toBe('new-john.doe@email.com');
expect(usernameEntity.domainEvents.length).toBe(2);
expect(usernameEntity.domainEvents[1]).toBeInstanceOf(
UsernameUpdatedDomainEvent,
);
});
});
describe('Username entity delete', () => {
it('should delete a username entity', async () => {
const usernameEntity: UsernameEntity = await UsernameEntity.create(

View File

@ -0,0 +1,88 @@
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 { UpdateUsernameRequestDto } from '@modules/authentication/interface/grpc-controllers/dtos/update-username.request.dto';
import { UpdateUsernameGrpcController } from '@modules/authentication/interface/grpc-controllers/update-username.grpc.controller';
import { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
const updateUsernameRequest: UpdateUsernameRequestDto = {
userId: '78153e03-4861-4f58-a705-88526efee53b',
name: 'new-john.doe@email.com',
type: Type.EMAIL,
};
const mockCommandBus = {
execute: jest
.fn()
.mockImplementationOnce(() => 'new-john.doe@email.com')
.mockImplementationOnce(() => {
throw new UsernameAlreadyExistsException();
})
.mockImplementationOnce(() => {
throw new Error();
}),
};
describe('Update Username Grpc Controller', () => {
let updateUsernameGrpcController: UpdateUsernameGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
UpdateUsernameGrpcController,
],
}).compile();
updateUsernameGrpcController = module.get<UpdateUsernameGrpcController>(
UpdateUsernameGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(updateUsernameGrpcController).toBeDefined();
});
it('should update a username', async () => {
jest.spyOn(mockCommandBus, 'execute');
const result: IdResponse =
await updateUsernameGrpcController.updateUsername(updateUsernameRequest);
expect(result).toBeInstanceOf(IdResponse);
expect(result.id).toBe('new-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 updateUsernameGrpcController.updateUsername(updateUsernameRequest);
} 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 updateUsernameGrpcController.updateUsername(updateUsernameRequest);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -31,12 +31,13 @@ export class UsernameMapper
toDomain = (record: UsernameModel): UsernameEntity => {
const entity = new UsernameEntity({
id: record.authUuid,
id: record.username,
createdAt: new Date(record.createdAt),
updatedAt: new Date(record.updatedAt),
props: {
name: record.username,
type: Type[record.type],
userId: record.authUuid,
},
});
return entity;