diff --git a/src/Logitar.Identity.Domain/Logitar.Identity.Domain.csproj b/src/Logitar.Identity.Domain/Logitar.Identity.Domain.csproj index 68554ee..7723c7e 100644 --- a/src/Logitar.Identity.Domain/Logitar.Identity.Domain.csproj +++ b/src/Logitar.Identity.Domain/Logitar.Identity.Domain.csproj @@ -16,14 +16,14 @@ README.md https://github.com/Logitar/Identity git - 0.10.1.0 + 0.10.2.0 $(AssemblyVersion) LICENSE True - 0.10.1 + 0.10.2 en-CA True - Added serialization constructors to contact informations. + Implemented FindAsync user method. logitar;net;framework;identity;domain https://github.com/Logitar/Identity/tree/main/src/Logitar.Identity.Domain diff --git a/src/Logitar.Identity.Domain/Users/FoundUsers.cs b/src/Logitar.Identity.Domain/Users/FoundUsers.cs new file mode 100644 index 0000000..8566f5d --- /dev/null +++ b/src/Logitar.Identity.Domain/Users/FoundUsers.cs @@ -0,0 +1,70 @@ +namespace Logitar.Identity.Domain.Users; + +/// +/// The results of an user search. +/// +public record FoundUsers +{ + /// + /// Gets or sets the user found by unique identifier. + /// + public UserAggregate? ById { get; set; } + /// + /// Gets or sets the user found by unique name. + /// + public UserAggregate? ByUniqueName { get; set; } + /// + /// Gets or sets the user found by email address. + /// + public UserAggregate? ByEmail { get; set; } + + /// + /// Gets all found users. + /// + public IEnumerable All + { + get + { + List users = new(capacity: 3); + + if (ById != null) + { + users.Add(ById); + } + if (ByUniqueName != null) + { + users.Add(ByUniqueName); + } + if (ByEmail != null) + { + users.Add(ByEmail); + } + + return users.AsReadOnly(); + } + } + + /// + /// Returns the first user found, ordered by unique identifier, then by unique name, and then by email address. + /// + /// The first user found. + public UserAggregate First() => All.First(); + /// + /// Returns the first user found, ordered by unique identifier, then by unique name, and then by email address. + /// + /// The first user found, or null if none were found. + public UserAggregate? FirstOrDefault() => All.FirstOrDefault(); + + /// + /// Returns the single user found. + /// + /// More than one users have been found. + /// The single user found. + public UserAggregate Single() => All.Single(); + /// + /// Returns the single user found. + /// + /// More than one users have been found. + /// The single user found, or null if none were found. + public UserAggregate? SingleOrDefault() => All.SingleOrDefault(); +} diff --git a/src/Logitar.Identity.Domain/Users/IUserManager.cs b/src/Logitar.Identity.Domain/Users/IUserManager.cs index 15c3aad..d0fefbf 100644 --- a/src/Logitar.Identity.Domain/Users/IUserManager.cs +++ b/src/Logitar.Identity.Domain/Users/IUserManager.cs @@ -7,6 +7,15 @@ namespace Logitar.Identity.Domain.Users; /// public interface IUserManager { + /// + /// Tries finding an user by its unique identifier, unique name, or email address if they are unique. + /// + /// The identifier of the tenant in which to search. + /// The identifier of the user to find. + /// The cancellation token. + /// The found users. + Task FindAsync(string? tenantId, string id, CancellationToken cancellationToken = default); + /// /// Saves the specified user, performing model validation such as unique name and email address unicity. /// diff --git a/src/Logitar.Identity.Domain/Users/UserManager.cs b/src/Logitar.Identity.Domain/Users/UserManager.cs index 320bada..92846d1 100644 --- a/src/Logitar.Identity.Domain/Users/UserManager.cs +++ b/src/Logitar.Identity.Domain/Users/UserManager.cs @@ -1,4 +1,5 @@ -using Logitar.EventSourcing; +using FluentValidation; +using Logitar.EventSourcing; using Logitar.Identity.Domain.Sessions; using Logitar.Identity.Domain.Settings; using Logitar.Identity.Domain.Shared; @@ -37,6 +38,75 @@ public UserManager(ISessionRepository sessionRepository, IUserRepository userRep UserSettingsResolver = userSettingsResolver; } + /// + /// Tries finding an user by its unique identifier, unique name, or email address if they are unique. + /// + /// The identifier of the tenant in which to search. + /// The identifier of the user to find. + /// The cancellation token. + /// The found users. + public virtual async Task FindAsync(string? tenantIdValue, string id, CancellationToken cancellationToken) + { + IUserSettings userSettings = UserSettingsResolver.Resolve(); + + TenantId? tenantId = null; + try + { + tenantId = TenantId.TryCreate(tenantIdValue); + } + catch (ValidationException) + { + } + + UserId? userId = null; + try + { + userId = UserId.TryCreate(id); + } + catch (ValidationException) + { + } + + UniqueNameUnit? uniqueName = null; + try + { + uniqueName = new(userSettings.UniqueName, id); + } + catch (ValidationException) + { + } + + EmailUnit? email = null; + try + { + email = new(id); + } + catch (ValidationException) + { + } + + FoundUsers found = new(); + + if (userId != null) + { + found.ById = await UserRepository.LoadAsync(userId, cancellationToken); + } + if (uniqueName != null) + { + found.ByUniqueName = await UserRepository.LoadAsync(tenantId, uniqueName, cancellationToken); + } + if (email != null && userSettings.RequireUniqueEmail) + { + IEnumerable users = await UserRepository.LoadAsync(tenantId, email, cancellationToken); + if (users.Count() == 1) + { + found.ByEmail = users.Single(); + } + } + + return found; + } + /// /// Saves the specified user, performing model validation such as unique name and email address unicity. /// diff --git a/src/Logitar.Identity.Domain/Users/UserNotFoundException.cs b/src/Logitar.Identity.Domain/Users/UserNotFoundException.cs index bfaa8c9..91dead8 100644 --- a/src/Logitar.Identity.Domain/Users/UserNotFoundException.cs +++ b/src/Logitar.Identity.Domain/Users/UserNotFoundException.cs @@ -1,4 +1,5 @@ -using Logitar.Identity.Domain.Shared; +using Logitar.Identity.Domain.Settings; +using Logitar.Identity.Domain.Shared; namespace Logitar.Identity.Domain.Users; @@ -12,20 +13,25 @@ public class UserNotFoundException : InvalidCredentialsException /// public new const string ErrorMessage = "The specified user could not be found."; + private static readonly UniqueNameSettings _uniqueNameSettings = new() + { + AllowedCharacters = null // NOTE(fpion): strict validation is not required when deserializing an unique name. + }; + /// /// Gets or sets the identifier of the tenant in which the user was searched. /// - public string? TenantId + public TenantId? TenantId { - get => (string?)Data[nameof(TenantId)]; - private set => Data[nameof(TenantId)] = value; + get => TenantId.TryCreate((string?)Data[nameof(TenantId)]); + private set => Data[nameof(TenantId)] = value?.Value; } /// /// Gets or sets the unique name of the user who was searched. /// - public string UniqueName + public UniqueNameUnit UniqueName { - get => (string)Data[nameof(UniqueName)]!; + get => new(_uniqueNameSettings, (string)Data[nameof(UniqueName)]!); private set => Data[nameof(UniqueName)] = value; } @@ -34,14 +40,14 @@ public string UniqueName /// /// The identifier of the tenant in which the user was searched. /// The unique name of the user who was searched. - public UserNotFoundException(string? tenantId, string uniqueName) : base(BuildMessage(tenantId, uniqueName)) + public UserNotFoundException(TenantId? tenantId, UniqueNameUnit uniqueName) : base(BuildMessage(tenantId, uniqueName)) { TenantId = tenantId; UniqueName = uniqueName; } - private static string BuildMessage(string? tenantId, string uniqueName) => new ErrorMessageBuilder(ErrorMessage) - .AddData(nameof(TenantId), tenantId, "") - .AddData(nameof(UniqueName), uniqueName) + private static string BuildMessage(TenantId? tenantId, UniqueNameUnit uniqueName) => new ErrorMessageBuilder(ErrorMessage) + .AddData(nameof(TenantId), tenantId?.Value, "") + .AddData(nameof(UniqueName), uniqueName.Value) .Build(); } diff --git a/tests/Logitar.Identity.Domain.UnitTests/Users/FoundUsersTests.cs b/tests/Logitar.Identity.Domain.UnitTests/Users/FoundUsersTests.cs new file mode 100644 index 0000000..d39d4f1 --- /dev/null +++ b/tests/Logitar.Identity.Domain.UnitTests/Users/FoundUsersTests.cs @@ -0,0 +1,96 @@ +using Bogus; +using Logitar.Identity.Domain.Settings; +using Logitar.Identity.Domain.Shared; + +namespace Logitar.Identity.Domain.Users; + +[Trait(Traits.Category, Categories.Unit)] +public class FoundUsersTests +{ + private readonly Faker _faker = new(); + private readonly UniqueNameSettings _uniqueNameSettings = new(); + + [Fact(DisplayName = "All: it should return all the users found.")] + public void All_it_should_return_all_the_users_found() + { + FoundUsers users = new(); + Assert.Empty(users.All); + + UserAggregate byId = new(new UniqueNameUnit(_uniqueNameSettings, _faker.Person.UserName)); + UserAggregate byEmail = new(new UniqueNameUnit(_uniqueNameSettings, _faker.Person.Email)); + users = new() + { + ById = byId, + ByEmail = byEmail + }; + + IEnumerable all = users.All; + Assert.Equal(2, all.Count()); + Assert.Contains(byId, all); + Assert.Contains(byEmail, all); + } + + [Fact(DisplayName = "First: it should return the first user found.")] + public void First_it_should_return_the_first_user_found() + { + UserAggregate byUniqueName = new(new UniqueNameUnit(_uniqueNameSettings, _faker.Person.UserName)); + UserAggregate byEmail = new(new UniqueNameUnit(_uniqueNameSettings, _faker.Person.Email)); + FoundUsers users = new() + { + ByUniqueName = byUniqueName, + ByEmail = byEmail + }; + + UserAggregate first = users.First(); + Assert.Equal(byUniqueName, first); + } + + [Fact(DisplayName = "FirstOrDefault: it should return the first user found or null if none found.")] + public void FirstOrDefault_it_should_return_the_first_user_found_or_null_if_none_found() + { + FoundUsers users = new(); + Assert.Null(users.FirstOrDefault()); + + UserAggregate byUniqueName = new(new UniqueNameUnit(_uniqueNameSettings, _faker.Person.UserName)); + UserAggregate byEmail = new(new UniqueNameUnit(_uniqueNameSettings, _faker.Person.Email)); + users = new() + { + ByUniqueName = byUniqueName, + ByEmail = byEmail + }; + + UserAggregate? first = users.FirstOrDefault(); + Assert.NotNull(first); + Assert.Equal(byUniqueName, first); + } + + [Fact(DisplayName = "Single: it should return the only user found.")] + public void Single_it_should_return_the_only_user_found() + { + UserAggregate user = new(new UniqueNameUnit(_uniqueNameSettings, _faker.Person.UserName)); + FoundUsers users = new() + { + ById = user + }; + + UserAggregate single = users.Single(); + Assert.Equal(user, single); + } + + [Fact(DisplayName = "SingleOrDefault: it should return the only user found or null if none found.")] + public void SingleOrDefault_it_should_return_the_only_user_found_or_null_if_none_found() + { + FoundUsers users = new(); + Assert.Null(users.SingleOrDefault()); + + UserAggregate byId = new(new UniqueNameUnit(_uniqueNameSettings, _faker.Person.UserName)); + users = new() + { + ById = byId + }; + + UserAggregate? single = users.SingleOrDefault(); + Assert.NotNull(single); + Assert.Equal(byId, single); + } +} diff --git a/tests/Logitar.Identity.Domain.UnitTests/Users/UserManagerTests.cs b/tests/Logitar.Identity.Domain.UnitTests/Users/UserManagerTests.cs index a58022f..294bdf1 100644 --- a/tests/Logitar.Identity.Domain.UnitTests/Users/UserManagerTests.cs +++ b/tests/Logitar.Identity.Domain.UnitTests/Users/UserManagerTests.cs @@ -32,6 +32,104 @@ public UserManagerTests() _userManager = new(_sessionRepository.Object, _userRepository.Object, _userSettingsResolver.Object); } + [Fact(DisplayName = "FindAsync: it should find an user by email address.")] + public async Task FindAsync_it_should_find_an_user_by_email_address() + { + _userSettings.RequireUniqueEmail = true; + + TenantId tenantId = new("tests"); + UserAggregate user = new(new UniqueNameUnit(_userSettings.UniqueName, _faker.Person.UserName)); + user.SetEmail(new EmailUnit(_faker.Person.Email)); + Assert.NotNull(user.Email); + _userRepository.Setup(x => x.LoadAsync(tenantId, user.Email, _cancellationToken)).ReturnsAsync([user]); + + FoundUsers users = await _userManager.FindAsync(tenantId.Value, user.Email.Address, _cancellationToken); + Assert.NotNull(users.ByEmail); + Assert.Equal(user, users.ByEmail); + Assert.Single(users.All); + + _userRepository.Verify(x => x.LoadAsync(It.IsAny(), _cancellationToken), Times.Never); + _userRepository.Verify(x => x.LoadAsync(tenantId, It.Is(y => y.Value == user.Email.Address), _cancellationToken), Times.Once); + _userRepository.Verify(x => x.LoadAsync(tenantId, user.Email, _cancellationToken), Times.Once); + } + + [Fact(DisplayName = "FindAsync: it should find an user by ID.")] + public async Task FindAsync_it_should_find_an_user_by_Id() + { + UserAggregate user = new(new UniqueNameUnit(_userSettings.UniqueName, _faker.Person.UserName)); + _userRepository.Setup(x => x.LoadAsync(user.Id, _cancellationToken)).ReturnsAsync(user); + + FoundUsers users = await _userManager.FindAsync(tenantIdValue: null, user.Id.Value, _cancellationToken); + Assert.NotNull(users.ById); + Assert.Equal(user, users.ById); + Assert.Single(users.All); + + _userRepository.Verify(x => x.LoadAsync(user.Id, _cancellationToken), Times.Once); + _userRepository.Verify(x => x.LoadAsync(null, It.Is(y => y.Value == user.Id.Value), _cancellationToken), Times.Once); + _userRepository.Verify(x => x.LoadAsync(It.IsAny(), It.IsAny(), _cancellationToken), Times.Never); + } + + [Fact(DisplayName = "FindAsync: it should find an user by unique name.")] + public async Task FindAsync_it_should_find_an_user_by_unique_name() + { + TenantId tenantId = new("tests"); + UserAggregate user = new(new UniqueNameUnit(_userSettings.UniqueName, _faker.Person.UserName), tenantId); + _userRepository.Setup(x => x.LoadAsync(tenantId, user.UniqueName, _cancellationToken)).ReturnsAsync(user); + + FoundUsers users = await _userManager.FindAsync(tenantId.Value, user.UniqueName.Value, _cancellationToken); + Assert.NotNull(users.ByUniqueName); + Assert.Equal(user, users.ByUniqueName); + Assert.Single(users.All); + + _userRepository.Verify(x => x.LoadAsync(It.Is(y => y.Value == user.UniqueName.Value), _cancellationToken), Times.Once); + _userRepository.Verify(x => x.LoadAsync(tenantId, user.UniqueName, _cancellationToken), Times.Once); + _userRepository.Verify(x => x.LoadAsync(It.IsAny(), It.IsAny(), _cancellationToken), Times.Never); + } + + [Fact(DisplayName = "FindAsync: it should not find an user by email address when many are found.")] + public async Task FindAsync_it_should_not_find_an_user_by_email_address_when_many_are_found() + { + _userSettings.RequireUniqueEmail = true; + + EmailUnit email = new(_faker.Internet.Email()); + UserAggregate user1 = new(new UniqueNameUnit(_userSettings.UniqueName, _faker.Internet.UserName())); + user1.SetEmail(email); + UserAggregate user2 = new(new UniqueNameUnit(_userSettings.UniqueName, _faker.Internet.UserName())); + user2.SetEmail(email); + _userRepository.Setup(x => x.LoadAsync(null, email, _cancellationToken)).ReturnsAsync([user1, user2]); + + FoundUsers users = await _userManager.FindAsync(tenantIdValue: null, email.Address, _cancellationToken); + Assert.Empty(users.All); + + _userRepository.Verify(x => x.LoadAsync(It.IsAny(), _cancellationToken), Times.Never); + _userRepository.Verify(x => x.LoadAsync(null, It.Is(y => y.Value == email.Address), _cancellationToken), Times.Once); + _userRepository.Verify(x => x.LoadAsync(null, email, _cancellationToken), Times.Once); + } + + [Fact(DisplayName = "FindAsync: it should not search by email address when email addresses are not unique.")] + public async Task FindAsync_it_should_not_search_by_email_address_when_email_addresses_are_not_unique() + { + string emailAddress = _faker.Person.Email; + FoundUsers users = await _userManager.FindAsync(tenantIdValue: null, emailAddress, _cancellationToken); + Assert.Empty(users.All); + + _userRepository.Verify(x => x.LoadAsync(It.IsAny(), _cancellationToken), Times.Never); + _userRepository.Verify(x => x.LoadAsync(null, It.Is(y => y.Value == emailAddress), _cancellationToken), Times.Once); + _userRepository.Verify(x => x.LoadAsync(null, It.IsAny(), _cancellationToken), Times.Never); + } + + [Fact(DisplayName = "FindAsync: it should not search by tenant id when it is not valid.")] + public async Task FindAsync_it_should_not_search_by_tenant_id_when_it_is_not_valid() + { + string emailAddress = _faker.Person.Email; + FoundUsers users = await _userManager.FindAsync(emailAddress, emailAddress, _cancellationToken); + Assert.Empty(users.All); + + _userRepository.Verify(x => x.LoadAsync(It.IsAny(), _cancellationToken), Times.Never); + _userRepository.Verify(x => x.LoadAsync(null, It.Is(y => y.Value == emailAddress), _cancellationToken), Times.Once); + _userRepository.Verify(x => x.LoadAsync(null, It.IsAny(), _cancellationToken), Times.Never); + } + [Fact(DisplayName = "SaveAsync: it should allow multiple email address when not unique.")] public async Task SaveAsync_it_should_allow_multiple_email_address_when_not_unique() {