validate authentication use case
This commit is contained in:
parent
92ce0cd93a
commit
487ae9c38e
|
@ -23,6 +23,8 @@ import { UpdateUsernameGrpcController } from './interface/grpc-controllers/updat
|
||||||
import { UpdateUsernameService } from './core/application/commands/update-username/update-username.service';
|
import { UpdateUsernameService } from './core/application/commands/update-username/update-username.service';
|
||||||
import { UpdatePasswordGrpcController } from './interface/grpc-controllers/update-password.grpc.controller';
|
import { UpdatePasswordGrpcController } from './interface/grpc-controllers/update-password.grpc.controller';
|
||||||
import { UpdatePasswordService } from './core/application/commands/update-password/update-password.service';
|
import { UpdatePasswordService } from './core/application/commands/update-password/update-password.service';
|
||||||
|
import { ValidateAuthenticationGrpcController } from './interface/grpc-controllers/validate-authentication.grpc.controller';
|
||||||
|
import { ValidateAuthenticationQueryHandler } from './core/application/queries/validate-authentication/validate-authentication.query-handler';
|
||||||
|
|
||||||
const grpcControllers = [
|
const grpcControllers = [
|
||||||
CreateAuthenticationGrpcController,
|
CreateAuthenticationGrpcController,
|
||||||
|
@ -31,6 +33,7 @@ const grpcControllers = [
|
||||||
UpdateUsernameGrpcController,
|
UpdateUsernameGrpcController,
|
||||||
DeleteUsernameGrpcController,
|
DeleteUsernameGrpcController,
|
||||||
UpdatePasswordGrpcController,
|
UpdatePasswordGrpcController,
|
||||||
|
ValidateAuthenticationGrpcController,
|
||||||
];
|
];
|
||||||
|
|
||||||
const commandHandlers: Provider[] = [
|
const commandHandlers: Provider[] = [
|
||||||
|
@ -42,6 +45,8 @@ const commandHandlers: Provider[] = [
|
||||||
UpdatePasswordService,
|
UpdatePasswordService,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const queryHandlers: Provider[] = [ValidateAuthenticationQueryHandler];
|
||||||
|
|
||||||
const mappers: Provider[] = [AuthenticationMapper, UsernameMapper];
|
const mappers: Provider[] = [AuthenticationMapper, UsernameMapper];
|
||||||
|
|
||||||
const repositories: Provider[] = [
|
const repositories: Provider[] = [
|
||||||
|
@ -69,6 +74,7 @@ const orms: Provider[] = [PrismaService];
|
||||||
controllers: [...grpcControllers],
|
controllers: [...grpcControllers],
|
||||||
providers: [
|
providers: [
|
||||||
...commandHandlers,
|
...commandHandlers,
|
||||||
|
...queryHandlers,
|
||||||
...mappers,
|
...mappers,
|
||||||
...repositories,
|
...repositories,
|
||||||
...messageBrokers,
|
...messageBrokers,
|
||||||
|
|
|
@ -4,5 +4,6 @@ import { Type } from '../../domain/username.types';
|
||||||
|
|
||||||
export type UsernameRepositoryPort = RepositoryPort<UsernameEntity> & {
|
export type UsernameRepositoryPort = RepositoryPort<UsernameEntity> & {
|
||||||
findByType(userId: string, type: Type): Promise<UsernameEntity>;
|
findByType(userId: string, type: Type): Promise<UsernameEntity>;
|
||||||
|
findByName(name: string): Promise<UsernameEntity>;
|
||||||
updateUsername(oldName: string, entity: UsernameEntity): Promise<void>;
|
updateUsername(oldName: string, entity: UsernameEntity): Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
|
import { Inject, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ValidateAuthenticationQuery } from './validate-authentication.query';
|
||||||
|
import {
|
||||||
|
AUTHENTICATION_REPOSITORY,
|
||||||
|
USERNAME_REPOSITORY,
|
||||||
|
} from '@modules/authentication/authentication.di-tokens';
|
||||||
|
import { AuthenticationRepositoryPort } from '../../ports/authentication.repository.port';
|
||||||
|
import { UsernameRepositoryPort } from '../../ports/username.repository.port';
|
||||||
|
import { AuthenticationEntity } from '@modules/authentication/core/domain/authentication.entity';
|
||||||
|
import { UsernameEntity } from '@modules/authentication/core/domain/username.entity';
|
||||||
|
import { AggregateID, NotFoundException } from '@mobicoop/ddd-library';
|
||||||
|
|
||||||
|
@QueryHandler(ValidateAuthenticationQuery)
|
||||||
|
export class ValidateAuthenticationQueryHandler implements IQueryHandler {
|
||||||
|
constructor(
|
||||||
|
@Inject(AUTHENTICATION_REPOSITORY)
|
||||||
|
private readonly authenticationRepository: AuthenticationRepositoryPort,
|
||||||
|
@Inject(USERNAME_REPOSITORY)
|
||||||
|
private readonly usernameRepository: UsernameRepositoryPort,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
execute = async (
|
||||||
|
query: ValidateAuthenticationQuery,
|
||||||
|
): Promise<AggregateID> => {
|
||||||
|
let usernameEntity: UsernameEntity;
|
||||||
|
try {
|
||||||
|
usernameEntity = await this.usernameRepository.findByName(query.name);
|
||||||
|
} catch (e) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
let authenticationEntity: AuthenticationEntity;
|
||||||
|
try {
|
||||||
|
authenticationEntity = await this.authenticationRepository.findOneById(
|
||||||
|
usernameEntity.getProps().userId,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const isAuthenticated = await authenticationEntity.authenticate(
|
||||||
|
query.password,
|
||||||
|
);
|
||||||
|
if (isAuthenticated) return authenticationEntity.id;
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
} catch (e) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { QueryBase } from '@mobicoop/ddd-library';
|
||||||
|
|
||||||
|
export class ValidateAuthenticationQuery extends QueryBase {
|
||||||
|
readonly name: string;
|
||||||
|
readonly password: string;
|
||||||
|
|
||||||
|
constructor(name: string, password: string) {
|
||||||
|
super();
|
||||||
|
this.name = name;
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
}
|
|
@ -47,6 +47,9 @@ export class AuthenticationEntity extends AggregateRoot<AuthenticationProps> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authenticate = async (password: string): Promise<boolean> =>
|
||||||
|
await bcrypt.compare(password, this.props.password);
|
||||||
|
|
||||||
validate(): void {
|
validate(): void {
|
||||||
// entity business rules validation to protect it's invariant before saving entity to a database
|
// entity business rules validation to protect it's invariant before saving entity to a database
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,6 +54,11 @@ export class UsernameRepository
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
findByName = async (name: string): Promise<UsernameEntity> =>
|
||||||
|
this.findOne({
|
||||||
|
username: name,
|
||||||
|
});
|
||||||
|
|
||||||
updateUsername = async (
|
updateUsername = async (
|
||||||
oldName: string,
|
oldName: string,
|
||||||
entity: UsernameEntity,
|
entity: UsernameEntity,
|
||||||
|
|
|
@ -3,7 +3,7 @@ syntax = "proto3";
|
||||||
package authentication;
|
package authentication;
|
||||||
|
|
||||||
service AuthenticationService {
|
service AuthenticationService {
|
||||||
rpc Validate(AuthenticationByUsernamePassword) returns (Id);
|
rpc Validate(AuthenticationByNamePassword) returns (Id);
|
||||||
rpc Create(Authentication) returns (Id);
|
rpc Create(Authentication) returns (Id);
|
||||||
rpc AddUsername(Username) returns (Id);
|
rpc AddUsername(Username) returns (Id);
|
||||||
rpc UpdatePassword(Password) returns (Id);
|
rpc UpdatePassword(Password) returns (Id);
|
||||||
|
@ -12,8 +12,8 @@ service AuthenticationService {
|
||||||
rpc Delete(UserId) returns (Empty);
|
rpc Delete(UserId) returns (Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
message AuthenticationByUsernamePassword {
|
message AuthenticationByNamePassword {
|
||||||
string username = 1;
|
string name = 1;
|
||||||
string password = 2;
|
string password = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class ValidateAuthenticationRequestDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
password: string;
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
import {
|
||||||
|
AggregateID,
|
||||||
|
IdResponse,
|
||||||
|
RpcExceptionCode,
|
||||||
|
RpcValidationPipe,
|
||||||
|
} from '@mobicoop/ddd-library';
|
||||||
|
import { Controller, UsePipes } from '@nestjs/common';
|
||||||
|
import { QueryBus } from '@nestjs/cqrs';
|
||||||
|
import { GrpcMethod, RpcException } from '@nestjs/microservices';
|
||||||
|
import { ValidateAuthenticationRequestDto } from './dtos/validate-authentication.request.dto';
|
||||||
|
import { ValidateAuthenticationQuery } from '@modules/authentication/core/application/queries/validate-authentication/validate-authentication.query';
|
||||||
|
|
||||||
|
@UsePipes(
|
||||||
|
new RpcValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidUnknownValues: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@Controller()
|
||||||
|
export class ValidateAuthenticationGrpcController {
|
||||||
|
constructor(private readonly queryBus: QueryBus) {}
|
||||||
|
|
||||||
|
@GrpcMethod('AuthenticationService', 'Validate')
|
||||||
|
async validate(data: ValidateAuthenticationRequestDto): Promise<IdResponse> {
|
||||||
|
try {
|
||||||
|
const aggregateID: AggregateID = await this.queryBus.execute(
|
||||||
|
new ValidateAuthenticationQuery(data.name, data.password),
|
||||||
|
);
|
||||||
|
return new IdResponse(aggregateID);
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new RpcException({
|
||||||
|
code: RpcExceptionCode.PERMISSION_DENIED,
|
||||||
|
message: 'Permission denied',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -76,3 +76,21 @@ describe('Authentication password update', () => {
|
||||||
expect(authenticationEntity.getProps().password).not.toBe(oldPassword);
|
expect(authenticationEntity.getProps().password).not.toBe(oldPassword);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('Authentication password validation', () => {
|
||||||
|
it('should validate a valid password', async () => {
|
||||||
|
const authenticationEntity: AuthenticationEntity =
|
||||||
|
await AuthenticationEntity.create(createAuthenticationProps);
|
||||||
|
const result: boolean = await authenticationEntity.authenticate(
|
||||||
|
'somePassword',
|
||||||
|
);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
});
|
||||||
|
it('should not validate an invalid password', async () => {
|
||||||
|
const authenticationEntity: AuthenticationEntity =
|
||||||
|
await AuthenticationEntity.create(createAuthenticationProps);
|
||||||
|
const result: boolean = await authenticationEntity.authenticate(
|
||||||
|
'someWrongPassword',
|
||||||
|
);
|
||||||
|
expect(result).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { AggregateID, NotFoundException } from '@mobicoop/ddd-library';
|
||||||
|
import {
|
||||||
|
AUTHENTICATION_REPOSITORY,
|
||||||
|
USERNAME_REPOSITORY,
|
||||||
|
} from '@modules/authentication/authentication.di-tokens';
|
||||||
|
import { ValidateAuthenticationQuery } from '@modules/authentication/core/application/queries/validate-authentication/validate-authentication.query';
|
||||||
|
import { ValidateAuthenticationQueryHandler } from '@modules/authentication/core/application/queries/validate-authentication/validate-authentication.query-handler';
|
||||||
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
|
||||||
|
const validName = 'john.doe@email.com';
|
||||||
|
const furtherInvalidName = 'validNameButInvalidAuthenticationForSomeReason';
|
||||||
|
const validUserId = '165192d4-398a-4469-a16b-98c02cc6f531';
|
||||||
|
const invalidUserId = 'b3b75798-7104-4151-adab-11fe16e32d85';
|
||||||
|
const validPassword = 'V@l1dP@$$w0rd';
|
||||||
|
const invalidPassword = '1nV@l1dP@$$w0rd';
|
||||||
|
|
||||||
|
const mockUsernameRepository = {
|
||||||
|
findByName: jest.fn().mockImplementation((name: string) => {
|
||||||
|
if (name == validName) {
|
||||||
|
return {
|
||||||
|
getProps: jest.fn().mockImplementation(() => ({
|
||||||
|
userId: validUserId,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (name == furtherInvalidName) {
|
||||||
|
return {
|
||||||
|
getProps: jest.fn().mockImplementation(() => ({
|
||||||
|
userId: invalidUserId,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error();
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAuthenticationEntity = {
|
||||||
|
id: validUserId,
|
||||||
|
authenticate: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((password: string) => password === validPassword),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAuthenticationRepository = {
|
||||||
|
findOneById: jest.fn().mockImplementation((id: string) => {
|
||||||
|
if (id === validUserId) return mockAuthenticationEntity;
|
||||||
|
throw new Error();
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Validate Authentication Query Handler', () => {
|
||||||
|
let validateAuthenticationQueryHandler: ValidateAuthenticationQueryHandler;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: AUTHENTICATION_REPOSITORY,
|
||||||
|
useValue: mockAuthenticationRepository,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: USERNAME_REPOSITORY,
|
||||||
|
useValue: mockUsernameRepository,
|
||||||
|
},
|
||||||
|
ValidateAuthenticationQueryHandler,
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
validateAuthenticationQueryHandler =
|
||||||
|
module.get<ValidateAuthenticationQueryHandler>(
|
||||||
|
ValidateAuthenticationQueryHandler,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(validateAuthenticationQueryHandler).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('execution', () => {
|
||||||
|
it('should validate an authentication query', async () => {
|
||||||
|
const validateAuthenticationQuery = new ValidateAuthenticationQuery(
|
||||||
|
validName,
|
||||||
|
validPassword,
|
||||||
|
);
|
||||||
|
const id: AggregateID = await validateAuthenticationQueryHandler.execute(
|
||||||
|
validateAuthenticationQuery,
|
||||||
|
);
|
||||||
|
expect(id).toBe(validUserId);
|
||||||
|
});
|
||||||
|
it('should not validate an authentication query if username is invalid', async () => {
|
||||||
|
const validateAuthenticationQuery = new ValidateAuthenticationQuery(
|
||||||
|
'invalidName',
|
||||||
|
validPassword,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
validateAuthenticationQueryHandler.execute(validateAuthenticationQuery),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
it('should not validate an authentication query if authentication is not found', async () => {
|
||||||
|
const validateAuthenticationQuery = new ValidateAuthenticationQuery(
|
||||||
|
furtherInvalidName,
|
||||||
|
validPassword,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
validateAuthenticationQueryHandler.execute(validateAuthenticationQuery),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
it('should not validate a failing authentication query', async () => {
|
||||||
|
const validateAuthenticationQuery = new ValidateAuthenticationQuery(
|
||||||
|
validName,
|
||||||
|
invalidPassword,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
validateAuthenticationQueryHandler.execute(validateAuthenticationQuery),
|
||||||
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -59,6 +59,11 @@ describe('Username repository', () => {
|
||||||
mockMessagePublisher,
|
mockMessagePublisher,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(usernameRepository).toBeDefined();
|
expect(usernameRepository).toBeDefined();
|
||||||
});
|
});
|
||||||
|
@ -75,6 +80,17 @@ describe('Username repository', () => {
|
||||||
});
|
});
|
||||||
expect(username.getProps().name).toBe('john.doe@email.com');
|
expect(username.getProps().name).toBe('john.doe@email.com');
|
||||||
});
|
});
|
||||||
|
it('should find a username by its name', async () => {
|
||||||
|
jest.spyOn(usernameRepository, 'findOne');
|
||||||
|
const username: UsernameEntity = await usernameRepository.findByName(
|
||||||
|
'john.doe@email.com',
|
||||||
|
);
|
||||||
|
expect(usernameRepository.findOne).toHaveBeenCalledTimes(1);
|
||||||
|
expect(usernameRepository.findOne).toHaveBeenCalledWith({
|
||||||
|
username: 'john.doe@email.com',
|
||||||
|
});
|
||||||
|
expect(username.getProps().name).toBe('john.doe@email.com');
|
||||||
|
});
|
||||||
it('should update a username', async () => {
|
it('should update a username', async () => {
|
||||||
jest.spyOn(usernameRepository, 'updateWhere');
|
jest.spyOn(usernameRepository, 'updateWhere');
|
||||||
const usernameToUpdate: UsernameEntity = await UsernameEntity.create({
|
const usernameToUpdate: UsernameEntity = await UsernameEntity.create({
|
||||||
|
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { IdResponse } from '@mobicoop/ddd-library';
|
||||||
|
import { RpcExceptionCode } from '@mobicoop/ddd-library';
|
||||||
|
import { ValidateAuthenticationRequestDto } from '@modules/authentication/interface/grpc-controllers/dtos/validate-authentication.request.dto';
|
||||||
|
import { ValidateAuthenticationGrpcController } from '@modules/authentication/interface/grpc-controllers/validate-authentication.grpc.controller';
|
||||||
|
import { QueryBus } from '@nestjs/cqrs';
|
||||||
|
import { RpcException } from '@nestjs/microservices';
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
|
||||||
|
const validateAuthenticationRequest: ValidateAuthenticationRequestDto = {
|
||||||
|
password: 'John123',
|
||||||
|
name: 'john.doe@email.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockQueryBus = {
|
||||||
|
execute: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce(() => '78153e03-4861-4f58-a705-88526efee53b')
|
||||||
|
.mockImplementationOnce(() => {
|
||||||
|
throw new Error();
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Validate Authentication Grpc Controller', () => {
|
||||||
|
let validateAuthenticationGrpcController: ValidateAuthenticationGrpcController;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: QueryBus,
|
||||||
|
useValue: mockQueryBus,
|
||||||
|
},
|
||||||
|
ValidateAuthenticationGrpcController,
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
validateAuthenticationGrpcController =
|
||||||
|
module.get<ValidateAuthenticationGrpcController>(
|
||||||
|
ValidateAuthenticationGrpcController,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(validateAuthenticationGrpcController).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate an authentication', async () => {
|
||||||
|
jest.spyOn(mockQueryBus, 'execute');
|
||||||
|
const result: IdResponse =
|
||||||
|
await validateAuthenticationGrpcController.validate(
|
||||||
|
validateAuthenticationRequest,
|
||||||
|
);
|
||||||
|
expect(result).toBeInstanceOf(IdResponse);
|
||||||
|
expect(result.id).toBe('78153e03-4861-4f58-a705-88526efee53b');
|
||||||
|
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw a dedicated RpcException if authentication fails', async () => {
|
||||||
|
jest.spyOn(mockQueryBus, 'execute');
|
||||||
|
expect.assertions(3);
|
||||||
|
try {
|
||||||
|
await validateAuthenticationGrpcController.validate(
|
||||||
|
validateAuthenticationRequest,
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e).toBeInstanceOf(RpcException);
|
||||||
|
expect(e.error.code).toBe(RpcExceptionCode.PERMISSION_DENIED);
|
||||||
|
}
|
||||||
|
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue