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()
{