diff --git a/NOTE.MD b/NOTE.MD index 5fcc655c..67f7a67e 100644 --- a/NOTE.MD +++ b/NOTE.MD @@ -3,4 +3,13 @@ - Role/Permission Management APIs - CRUD roles - Email Verification - Confirm email - 2FA/MFA - Two-factor authentication -- Social Login - Google, Facebook, etc. \ No newline at end of file +- Social Login - Google, Facebook, etc. + + +⚠️ Vấn đề phát hiện: +/connect/token endpoint không phản hồi - OAuth2 token endpoint bị treo khi gửi request. Có vẻ OpenIddict Server middleware không xử lý request đúng cách. + +Bạn muốn tôi làm gì tiếp: +Fix OAuth2 /connect/token endpoint - Debug và sửa lỗi OpenIddict configuration +Chỉ cần database connection - Nếu chỉ cần verify database connection thì đã hoàn thành +Tiếp tục với task khác - Cần hướng dẫn cụ thể \ No newline at end of file diff --git a/services/iam-service-net/src/IamService.API/Program.cs b/services/iam-service-net/src/IamService.API/Program.cs index bbc3adf3..5f9fa9df 100644 --- a/services/iam-service-net/src/IamService.API/Program.cs +++ b/services/iam-service-net/src/IamService.API/Program.cs @@ -194,6 +194,23 @@ try app.UseCors(); app.UseRouting(); + // EN: Debug middleware for /connect/* endpoints + // VI: Debug middleware cho /connect/* endpoints + app.Use(async (context, next) => + { + if (context.Request.Path.StartsWithSegments("/connect")) + { + Log.Information(">>> [DEBUG] Request to {Path} - Method: {Method}", + context.Request.Path, context.Request.Method); + } + await next(); + if (context.Request.Path.StartsWithSegments("/connect")) + { + Log.Information("<<< [DEBUG] Response from {Path} - Status: {StatusCode}", + context.Request.Path, context.Response.StatusCode); + } + }); + // EN: Authentication and Authorization / VI: Xác thực và phân quyền app.UseAuthentication(); app.UseAuthorization(); diff --git a/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs b/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs index 3c2fd982..b52b2355 100644 --- a/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs +++ b/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs @@ -102,27 +102,29 @@ public static class DependencyInjection // EN: Register scopes // VI: Đăng ký scopes - options.RegisterScopes("openid", "profile", "email", "roles", "api"); + options.RegisterScopes("openid", "profile", "email", "roles", "api", "offline_access"); // EN: Token lifetimes // VI: Thời hạn token options.SetAccessTokenLifetime(TimeSpan.FromMinutes(15)) .SetRefreshTokenLifetime(TimeSpan.FromDays(7)); - // EN: Development settings - Disable HTTPS requirement for local dev - // VI: Cài đặt development - Tắt yêu cầu HTTPS cho dev local + // EN: Development settings + // VI: Cài đặt development options.AddDevelopmentEncryptionCertificate() - .AddDevelopmentSigningCertificate(); + .AddDevelopmentSigningCertificate() + .DisableAccessTokenEncryption(); // EN: Disable encryption for dev / VI: Tắt mã hóa cho dev - // EN: Accept anonymous clients (for password flow) - // VI: Chấp nhận anonymous clients (cho password flow) + // EN: Accept anonymous clients (for password flow without client_id) + // VI: Chấp nhận anonymous clients (cho password flow không cần client_id) options.AcceptAnonymousClients(); - // EN: Disable client authentication - // VI: Tắt client authentication (for development) + // EN: Configure ASP.NET Core integration + // VI: Cấu hình tích hợp ASP.NET Core options.UseAspNetCore() .EnableTokenEndpointPassthrough() - .EnableUserinfoEndpointPassthrough(); + .EnableUserinfoEndpointPassthrough() + .DisableTransportSecurityRequirement(); // EN: Allow HTTP for dev / VI: Cho phép HTTP cho dev }) // EN: Register the OpenIddict validation components // VI: Đăng ký OpenIddict validation components diff --git a/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/AuthControllerTests.cs b/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/AuthControllerTests.cs new file mode 100644 index 00000000..1fbd2ed2 --- /dev/null +++ b/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/AuthControllerTests.cs @@ -0,0 +1,179 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Identity; +using Xunit; +using IamService.API.Application.Commands.Auth; +using IamService.API.Application.Common; +using IamService.Domain.AggregatesModel.UserAggregate; + +namespace IamService.FunctionalTests.Controllers; + +/// +/// EN: Functional tests for AuthController endpoints. +/// VI: Functional tests cho các endpoints của AuthController. +/// +public class AuthControllerTests : IClassFixture +{ + private readonly HttpClient _client; + private readonly CustomWebApplicationFactory _factory; + + public AuthControllerTests(CustomWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + [Fact] + public async Task Register_WithValidData_ShouldReturn201() + { + // Arrange + var request = new RegisterUserCommand( + $"test_{Guid.NewGuid()}@example.com", + "Password123!", + "John", + "Doe"); + + // Act + var response = await _client.PostAsJsonAsync("/api/v1/auth/register", request); + + // Assert + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var result = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(result); + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Equal(request.Email, result.Data.Email); + } + + [Fact] + public async Task Register_WithInvalidEmail_ShouldReturn400() + { + // Arrange + var request = new + { + Email = "invalid-email", + Password = "Password123!", + FirstName = "John", + LastName = "Doe" + }; + + // Act + var response = await _client.PostAsJsonAsync("/api/v1/auth/register", request); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Register_WithWeakPassword_ShouldReturn400() + { + // Arrange + var request = new + { + Email = "test@example.com", + Password = "weak", + FirstName = "John", + LastName = "Doe" + }; + + // Act + var response = await _client.PostAsJsonAsync("/api/v1/auth/register", request); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Register_WithDuplicateEmail_ShouldReturn409() + { + // Arrange + var email = $"duplicate_{Guid.NewGuid()}@example.com"; + var request = new RegisterUserCommand(email, "Password123!", "John", "Doe"); + + // First registration + await _client.PostAsJsonAsync("/api/v1/auth/register", request); + + // Act - Second registration with same email + var response = await _client.PostAsJsonAsync("/api/v1/auth/register", request); + + // Assert + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task Token_WithValidCredentials_ShouldReturnTokens() + { + // Arrange - Register user first + var email = $"login_{Guid.NewGuid()}@example.com"; + var password = "Password123!"; + + await _client.PostAsJsonAsync("/api/v1/auth/register", new + { + Email = email, + Password = password, + FirstName = "Login", + LastName = "Test" + }); + + // Activate user (in real scenario, need to verify email first) + using var scope = _factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = await userManager.FindByEmailAsync(email); + if (user != null) + { + user.Activate(); + await userManager.UpdateAsync(user); + } + + // Act - Login + var tokenRequest = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "password", + ["username"] = email, + ["password"] = password, + ["scope"] = "openid profile email offline_access" + }); + + var response = await _client.PostAsync("/connect/token", tokenRequest); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.NotEmpty(result.access_token); + Assert.Equal("Bearer", result.token_type); + } + + [Fact] + public async Task Token_WithInvalidCredentials_ShouldReturn400() + { + // Arrange + var tokenRequest = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "password", + ["username"] = "nonexistent@example.com", + ["password"] = "WrongPassword123!", + ["scope"] = "openid" + }); + + // Act + var response = await _client.PostAsync("/connect/token", tokenRequest); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + private record TokenResponse( + string access_token, + string token_type, + int expires_in, + string? refresh_token, + string? scope); +} diff --git a/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/UsersControllerTests.cs b/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/UsersControllerTests.cs new file mode 100644 index 00000000..da038fe5 --- /dev/null +++ b/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/UsersControllerTests.cs @@ -0,0 +1,169 @@ +using System.Net; +using System.Net.Http.Json; +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Identity; +using Xunit; +using IamService.API.Application.Common; +using IamService.Domain.AggregatesModel.UserAggregate; + +namespace IamService.FunctionalTests.Controllers; + +/// +/// EN: Functional tests for UsersController endpoints. +/// VI: Functional tests cho các endpoints của UsersController. +/// +public class UsersControllerTests : IClassFixture +{ + private readonly HttpClient _client; + private readonly CustomWebApplicationFactory _factory; + + public UsersControllerTests(CustomWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + private async Task GetAccessTokenAsync() + { + // Register and login a user + var email = $"user_{Guid.NewGuid()}@example.com"; + var password = "Password123!"; + + await _client.PostAsJsonAsync("/api/v1/auth/register", new + { + Email = email, + Password = password, + FirstName = "Test", + LastName = "User" + }); + + // Activate user + using var scope = _factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = await userManager.FindByEmailAsync(email); + if (user != null) + { + user.Activate(); + await userManager.UpdateAsync(user); + } + + // Get token + var tokenRequest = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "password", + ["username"] = email, + ["password"] = password, + ["scope"] = "openid profile email offline_access" + }); + + var response = await _client.PostAsync("/connect/token", tokenRequest); + var result = await response.Content.ReadFromJsonAsync(); + return result?.access_token ?? throw new Exception("Failed to get token"); + } + + [Fact] + public async Task GetUsers_WithoutAuth_ShouldReturn401() + { + // Act + var response = await _client.GetAsync("/api/v1/users"); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GetUsers_WithAuth_ShouldReturn200() + { + // Arrange + var token = await GetAccessTokenAsync(); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // Act + var response = await _client.GetAsync("/api/v1/users"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetMe_WithAuth_ShouldReturnCurrentUser() + { + // Arrange + var token = await GetAccessTokenAsync(); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // Act + var response = await _client.GetAsync("/api/v1/users/me"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(result); + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.NotEmpty(result.Data.Id); + } + + [Fact] + public async Task GetMe_WithoutAuth_ShouldReturn401() + { + // Act + var response = await _client.GetAsync("/api/v1/users/me"); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GetUserById_WithValidId_ShouldReturnUser() + { + // Arrange + var email = $"getbyid_{Guid.NewGuid()}@example.com"; + var password = "Password123!"; + + // Register user + var registerResponse = await _client.PostAsJsonAsync("/api/v1/auth/register", new + { + Email = email, + Password = password, + FirstName = "GetById", + LastName = "Test" + }); + var registerResult = await registerResponse.Content.ReadFromJsonAsync>(); + var userId = registerResult?.Data?.UserId; + + // Get auth token + var token = await GetAccessTokenAsync(); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // Act + var response = await _client.GetAsync($"/api/v1/users/{userId}"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetUserById_WithInvalidId_ShouldReturn404() + { + // Arrange + var token = await GetAccessTokenAsync(); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + var randomId = Guid.NewGuid(); + + // Act + var response = await _client.GetAsync($"/api/v1/users/{randomId}"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + private record TokenResponse(string access_token, string token_type, int expires_in); + private record RegisterResult(Guid UserId, string Email); +} diff --git a/services/iam-service-net/tests/IamService.FunctionalTests/CustomWebApplicationFactory.cs b/services/iam-service-net/tests/IamService.FunctionalTests/CustomWebApplicationFactory.cs index e4d801e9..194eb1d3 100644 --- a/services/iam-service-net/tests/IamService.FunctionalTests/CustomWebApplicationFactory.cs +++ b/services/iam-service-net/tests/IamService.FunctionalTests/CustomWebApplicationFactory.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using IamService.Infrastructure; namespace IamService.FunctionalTests; @@ -18,31 +19,30 @@ public class CustomWebApplicationFactory : WebApplicationFactory builder.ConfigureServices(services => { - // EN: Remove the existing DbContext registration - // VI: Xóa đăng ký DbContext hiện tại - var descriptor = services.SingleOrDefault( - d => d.ServiceType == typeof(DbContextOptions)); + // EN: Remove ALL DbContext-related registrations + // VI: Xóa TẤT CẢ các đăng ký liên quan đến DbContext + var descriptorsToRemove = services.Where( + d => d.ServiceType == typeof(DbContextOptions) || + d.ServiceType == typeof(IamServiceContext) || + d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true || + d.ImplementationType?.FullName?.Contains("Npgsql") == true) + .ToList(); - if (descriptor != null) + foreach (var descriptor in descriptorsToRemove) { services.Remove(descriptor); } - - // EN: Remove DbContext service - // VI: Xóa DbContext service - var dbContextDescriptor = services.SingleOrDefault( - d => d.ServiceType == typeof(IamServiceContext)); - - if (dbContextDescriptor != null) - { - services.Remove(dbContextDescriptor); - } + + // EN: Remove the DbContextOptions generic + // VI: Xóa DbContextOptions generic + services.RemoveAll(typeof(DbContextOptions)); // EN: Add in-memory database for testing // VI: Thêm in-memory database để test services.AddDbContext(options => { options.UseInMemoryDatabase("TestDatabase_" + Guid.NewGuid().ToString()); + options.EnableSensitiveDataLogging(); }); // EN: Ensure database is created with seed data diff --git a/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/ChangePasswordCommandHandlerTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/ChangePasswordCommandHandlerTests.cs new file mode 100644 index 00000000..06fd8d43 --- /dev/null +++ b/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/ChangePasswordCommandHandlerTests.cs @@ -0,0 +1,90 @@ +using Xunit; +using Moq; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using IamService.API.Application.Commands.Auth; +using IamService.Domain.AggregatesModel.UserAggregate; +using IamService.Domain.Exceptions; + +namespace IamService.UnitTests.Application.Commands; + +/// +/// EN: Unit tests for ChangePasswordCommandHandler. +/// VI: Unit tests cho ChangePasswordCommandHandler. +/// +public class ChangePasswordCommandHandlerTests +{ + private readonly Mock> _userManagerMock; + private readonly Mock> _loggerMock; + private readonly ChangePasswordCommandHandler _handler; + + public ChangePasswordCommandHandlerTests() + { + var store = new Mock>(); + _userManagerMock = new Mock>( + store.Object, null!, null!, null!, null!, null!, null!, null!, null!); + _loggerMock = new Mock>(); + _handler = new ChangePasswordCommandHandler(_userManagerMock.Object, _loggerMock.Object); + } + + [Fact] + public async Task Handle_WithValidData_ShouldChangePassword() + { + // Arrange + var userId = Guid.NewGuid(); + var user = new ApplicationUser("test@example.com", "John", "Doe"); + var command = new ChangePasswordCommand(userId, "OldPassword123!", "NewPassword123!"); + + _userManagerMock + .Setup(x => x.FindByIdAsync(userId.ToString())) + .ReturnsAsync(user); + + _userManagerMock + .Setup(x => x.ChangePasswordAsync(user, command.CurrentPassword, command.NewPassword)) + .ReturnsAsync(IdentityResult.Success); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task Handle_WithInvalidUserId_ShouldThrowDomainException() + { + // Arrange + var userId = Guid.NewGuid(); + var command = new ChangePasswordCommand(userId, "OldPassword123!", "NewPassword123!"); + + _userManagerMock + .Setup(x => x.FindByIdAsync(userId.ToString())) + .ReturnsAsync((ApplicationUser?)null); + + // Act & Assert + await Assert.ThrowsAsync(() => + _handler.Handle(command, CancellationToken.None)); + } + + [Fact] + public async Task Handle_WithWrongCurrentPassword_ShouldThrowException() + { + // Arrange + var userId = Guid.NewGuid(); + var user = new ApplicationUser("test@example.com", "John", "Doe"); + var command = new ChangePasswordCommand(userId, "WrongPassword!", "NewPassword123!"); + + _userManagerMock + .Setup(x => x.FindByIdAsync(userId.ToString())) + .ReturnsAsync(user); + + _userManagerMock + .Setup(x => x.ChangePasswordAsync(user, command.CurrentPassword, command.NewPassword)) + .ReturnsAsync(IdentityResult.Failed( + new IdentityError { Code = "PasswordMismatch", Description = "Incorrect password" })); + + // Act & Assert + await Assert.ThrowsAsync(() => + _handler.Handle(command, CancellationToken.None)); + } +} diff --git a/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/RegisterUserCommandHandlerTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/RegisterUserCommandHandlerTests.cs new file mode 100644 index 00000000..1c0aca3d --- /dev/null +++ b/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/RegisterUserCommandHandlerTests.cs @@ -0,0 +1,100 @@ +using Xunit; +using Moq; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using IamService.API.Application.Commands.Auth; +using IamService.Domain.AggregatesModel.UserAggregate; + +namespace IamService.UnitTests.Application.Commands; + +/// +/// EN: Unit tests for RegisterUserCommandHandler. +/// VI: Unit tests cho RegisterUserCommandHandler. +/// +public class RegisterUserCommandHandlerTests +{ + private readonly Mock> _userManagerMock; + private readonly Mock> _loggerMock; + private readonly RegisterUserCommandHandler _handler; + + public RegisterUserCommandHandlerTests() + { + var store = new Mock>(); + _userManagerMock = new Mock>( + store.Object, null!, null!, null!, null!, null!, null!, null!, null!); + _loggerMock = new Mock>(); + _handler = new RegisterUserCommandHandler(_userManagerMock.Object, _loggerMock.Object); + } + + [Fact] + public async Task Handle_WithValidData_ShouldCreateUser() + { + // Arrange + var command = new RegisterUserCommand( + "test@example.com", + "Password123!", + "John", + "Doe"); + + _userManagerMock + .Setup(x => x.FindByEmailAsync(command.Email)) + .ReturnsAsync((ApplicationUser?)null); + + _userManagerMock + .Setup(x => x.CreateAsync(It.IsAny(), command.Password)) + .ReturnsAsync(IdentityResult.Success); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(command.Email, result.Email); + Assert.NotEqual(Guid.Empty, result.UserId); + } + + [Fact] + public async Task Handle_WithExistingEmail_ShouldThrowException() + { + // Arrange + var command = new RegisterUserCommand( + "existing@example.com", + "Password123!", + "John", + "Doe"); + + var existingUser = new ApplicationUser(command.Email, "Existing", "User"); + + _userManagerMock + .Setup(x => x.FindByEmailAsync(command.Email)) + .ReturnsAsync(existingUser); + + // Act & Assert + await Assert.ThrowsAsync(() => + _handler.Handle(command, CancellationToken.None)); + } + + [Fact] + public async Task Handle_WhenCreateFails_ShouldThrowException() + { + // Arrange + var command = new RegisterUserCommand( + "test@example.com", + "weak", + "John", + "Doe"); + + _userManagerMock + .Setup(x => x.FindByEmailAsync(command.Email)) + .ReturnsAsync((ApplicationUser?)null); + + _userManagerMock + .Setup(x => x.CreateAsync(It.IsAny(), command.Password)) + .ReturnsAsync(IdentityResult.Failed( + new IdentityError { Code = "PasswordTooWeak", Description = "Password is too weak" })); + + // Act & Assert + await Assert.ThrowsAsync(() => + _handler.Handle(command, CancellationToken.None)); + } +} diff --git a/services/iam-service-net/tests/IamService.UnitTests/Domain/ApplicationUserTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Domain/ApplicationUserTests.cs new file mode 100644 index 00000000..d66a3a3f --- /dev/null +++ b/services/iam-service-net/tests/IamService.UnitTests/Domain/ApplicationUserTests.cs @@ -0,0 +1,139 @@ +using Xunit; +using IamService.Domain.AggregatesModel.UserAggregate; + +namespace IamService.UnitTests.Domain; + +/// +/// EN: Unit tests for ApplicationUser domain entity. +/// VI: Unit tests cho domain entity ApplicationUser. +/// +public class ApplicationUserTests +{ + [Fact] + public void Create_WithValidData_ShouldSetProperties() + { + // Arrange + var email = "test@example.com"; + var firstName = "John"; + var lastName = "Doe"; + + // Act + var user = new ApplicationUser(email, firstName, lastName); + + // Assert + Assert.Equal(email, user.Email); + Assert.Equal(firstName, user.FirstName); + Assert.Equal(lastName, user.LastName); + Assert.Equal($"{firstName} {lastName}", user.FullName); + Assert.Equal(UserStatus.PendingVerification, user.Status); + Assert.NotEqual(default, user.CreatedAt); + } + + [Fact] + public void Create_WithEmptyEmail_ShouldThrowException() + { + // Arrange & Act & Assert + Assert.Throws(() => + new ApplicationUser("", "John", "Doe")); + } + + [Fact] + public void Create_WithEmptyFirstName_ShouldThrowException() + { + // Arrange & Act & Assert + Assert.Throws(() => + new ApplicationUser("test@example.com", "", "Doe")); + } + + [Fact] + public void Create_WithEmptyLastName_ShouldThrowException() + { + // Arrange & Act & Assert + Assert.Throws(() => + new ApplicationUser("test@example.com", "John", "")); + } + + [Fact] + public void Activate_WithPendingUser_ShouldSetStatusToActive() + { + // Arrange + var user = new ApplicationUser("test@example.com", "John", "Doe"); + + // Act + user.Activate(); + + // Assert + Assert.Equal(UserStatus.Active, user.Status); + } + + [Fact] + public void Disable_ShouldSetStatusToDisabled() + { + // Arrange + var user = new ApplicationUser("test@example.com", "John", "Doe"); + user.Activate(); + + // Act + user.Disable(); + + // Assert + Assert.Equal(UserStatus.Disabled, user.Status); + } + + [Fact] + public void Lock_ShouldSetStatusToLocked() + { + // Arrange + var user = new ApplicationUser("test@example.com", "John", "Doe"); + user.Activate(); + + // Act + user.Lock(); + + // Assert + Assert.Equal(UserStatus.Locked, user.Status); + } + + [Fact] + public void UpdateProfile_WithValidData_ShouldUpdateNames() + { + // Arrange + var user = new ApplicationUser("test@example.com", "John", "Doe"); + var newFirstName = "Jane"; + var newLastName = "Smith"; + + // Act + user.UpdateProfile(newFirstName, newLastName); + + // Assert + Assert.Equal(newFirstName, user.FirstName); + Assert.Equal(newLastName, user.LastName); + Assert.Equal($"{newFirstName} {newLastName}", user.FullName); + } + + [Fact] + public void UpdateProfile_WithEmptyFirstName_ShouldThrowException() + { + // Arrange + var user = new ApplicationUser("test@example.com", "John", "Doe"); + + // Act & Assert + Assert.Throws(() => + user.UpdateProfile("", "Smith")); + } + + [Fact] + public void RecordLogin_ShouldUpdateLastLoginAt() + { + // Arrange + var user = new ApplicationUser("test@example.com", "John", "Doe"); + var beforeLogin = DateTime.UtcNow; + + // Act + user.RecordLogin(); + + // Assert + Assert.NotNull(user.LastLoginAt); + Assert.True(user.LastLoginAt >= beforeLogin); + } +} diff --git a/services/iam-service-net/tests/IamService.UnitTests/Domain/UserStatusTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Domain/UserStatusTests.cs new file mode 100644 index 00000000..45447fe4 --- /dev/null +++ b/services/iam-service-net/tests/IamService.UnitTests/Domain/UserStatusTests.cs @@ -0,0 +1,57 @@ +using Xunit; +using IamService.Domain.AggregatesModel.UserAggregate; + +namespace IamService.UnitTests.Domain; + +/// +/// EN: Unit tests for UserStatus enumeration. +/// VI: Unit tests cho UserStatus enumeration. +/// +public class UserStatusTests +{ + [Fact] + public void UserStatus_Active_ShouldHaveCorrectIdAndName() + { + // Assert + Assert.Equal(1, UserStatus.Active.Id); + Assert.Equal("Active", UserStatus.Active.Name); + } + + [Fact] + public void UserStatus_Locked_ShouldHaveCorrectIdAndName() + { + // Assert + Assert.Equal(2, UserStatus.Locked.Id); + Assert.Equal("Locked", UserStatus.Locked.Name); + } + + [Fact] + public void UserStatus_Disabled_ShouldHaveCorrectIdAndName() + { + // Assert + Assert.Equal(3, UserStatus.Disabled.Id); + Assert.Equal("Disabled", UserStatus.Disabled.Name); + } + + [Fact] + public void UserStatus_PendingVerification_ShouldHaveCorrectIdAndName() + { + // Assert + Assert.Equal(4, UserStatus.PendingVerification.Id); + Assert.Equal("PendingVerification", UserStatus.PendingVerification.Name); + } + + [Fact] + public void GetAll_ShouldReturnAllStatuses() + { + // Act + var statuses = UserStatus.GetAll().ToList(); + + // Assert + Assert.Equal(4, statuses.Count); + Assert.Contains(UserStatus.Active, statuses); + Assert.Contains(UserStatus.Locked, statuses); + Assert.Contains(UserStatus.Disabled, statuses); + Assert.Contains(UserStatus.PendingVerification, statuses); + } +}