diff --git a/API/ROLAC.API.Tests/Services/UserManagementServiceTests.cs b/API/ROLAC.API.Tests/Services/UserManagementServiceTests.cs new file mode 100644 index 0000000..561504a --- /dev/null +++ b/API/ROLAC.API.Tests/Services/UserManagementServiceTests.cs @@ -0,0 +1,182 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Moq; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Users; +using ROLAC.API.Entities; +using ROLAC.API.Services; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class UserManagementServiceTests +{ + private static AppDbContext BuildDb() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options); + + private static Mock> BuildUserManager( + AppUser? findResult = null, + bool createOk = true, + IList? roles = null) + { + var store = new Mock>(); +#pragma warning disable CS8625 + var mgr = new Mock>( + store.Object, null, null, null, null, null, null, null, null); +#pragma warning restore CS8625 + mgr.Setup(m => m.FindByIdAsync(It.IsAny())) + .ReturnsAsync(findResult); + mgr.Setup(m => m.FindByEmailAsync(It.IsAny())) + .ReturnsAsync((AppUser?)null); + mgr.Setup(m => m.CreateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(createOk ? IdentityResult.Success + : IdentityResult.Failed(new IdentityError { Description = "fail" })); + mgr.Setup(m => m.AddToRolesAsync(It.IsAny(), It.IsAny>())) + .ReturnsAsync(IdentityResult.Success); + mgr.Setup(m => m.GetRolesAsync(It.IsAny())) + .ReturnsAsync(roles ?? new List { "member" }); + mgr.Setup(m => m.UpdateAsync(It.IsAny())) + .ReturnsAsync(IdentityResult.Success); + mgr.Setup(m => m.RemoveFromRolesAsync(It.IsAny(), It.IsAny>())) + .ReturnsAsync(IdentityResult.Success); + mgr.Setup(m => m.GeneratePasswordResetTokenAsync(It.IsAny())) + .ReturnsAsync("reset-token"); + mgr.Setup(m => m.ResetPasswordAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(IdentityResult.Success); + return mgr; + } + + // ── CreateAsync ────────────────────────────────────────────────────────── + + [Fact] + public async Task CreateAsync_ReturnsTempPassword() + { + using var db = BuildDb(); + // Seed a Member so MemberId validation passes + // Note: InMemory DB requires audit fields — we set them directly + var member = new Member + { + FirstName_en = "A", LastName_en = "B", + CreatedBy = "system", UpdatedBy = "system", + CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow, + }; + db.Members.Add(member); + await db.SaveChangesAsync(); + + var mgr = BuildUserManager(); + // Capture the AppUser passed to CreateAsync + AppUser? created = null; + mgr.Setup(m => m.CreateAsync(It.IsAny(), It.IsAny())) + .Callback((u, _) => { created = u; u.Id = Guid.NewGuid().ToString(); }) + .ReturnsAsync(IdentityResult.Success); + + // Mock Users queryable to return empty (no existing user for this member) + mgr.Setup(m => m.Users) + .Returns(new List().AsQueryable()); + + var svc = new UserManagementService(mgr.Object, db); + var result = await svc.CreateAsync(new CreateUserRequest + { + MemberId = member.Id, + Email = "test@rolac.org", + Roles = ["member"], + }); + + Assert.False(string.IsNullOrEmpty(result.TempPassword)); + Assert.Equal(12, result.TempPassword.Length); + Assert.NotNull(created); + Assert.Equal(member.Id, created!.MemberId); + } + + [Fact] + public async Task CreateAsync_Throws_WhenMemberNotFound() + { + using var db = BuildDb(); + var mgr = BuildUserManager(); + mgr.Setup(m => m.Users) + .Returns(new List().AsQueryable()); + var svc = new UserManagementService(mgr.Object, db); + + await Assert.ThrowsAsync(() => + svc.CreateAsync(new CreateUserRequest + { MemberId = 9999, Email = "x@y.com", Roles = ["member"] })); + } + + [Fact] + public async Task CreateAsync_Throws_WhenMemberAlreadyHasUser() + { + using var db = BuildDb(); + var member = new Member + { + FirstName_en = "A", LastName_en = "B", + CreatedBy = "system", UpdatedBy = "system", + CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow, + }; + db.Members.Add(member); + await db.SaveChangesAsync(); + + var existingUser = new AppUser + { + Id = Guid.NewGuid().ToString(), + UserName = "existing@test.com", + Email = "existing@test.com", + MemberId = member.Id, + }; + db.Users.Add(existingUser); + await db.SaveChangesAsync(); + + var mgr = BuildUserManager(); + // The service checks _userManager.Users — we need to return the existing user + mgr.Setup(m => m.Users) + .Returns(new List { existingUser }.AsQueryable()); + var svc = new UserManagementService(mgr.Object, db); + + await Assert.ThrowsAsync(() => + svc.CreateAsync(new CreateUserRequest + { MemberId = member.Id, Email = "new@test.com", Roles = ["member"] })); + } + + // ── DeactivateAsync ────────────────────────────────────────────────────── + + [Fact] + public async Task DeactivateAsync_SetsIsActiveFalse() + { + using var db = BuildDb(); + var user = new AppUser + { Id = "u1", UserName = "a@b.com", Email = "a@b.com", IsActive = true }; + var mgr = BuildUserManager(findResult: user); + var svc = new UserManagementService(mgr.Object, db); + + await svc.DeactivateAsync("u1"); + + Assert.False(user.IsActive); + Assert.Equal(DateTimeOffset.MaxValue, user.LockoutEnd); + } + + [Fact] + public async Task DeactivateAsync_ThrowsKeyNotFound_WhenUserMissing() + { + using var db = BuildDb(); + var mgr = BuildUserManager(findResult: null); + var svc = new UserManagementService(mgr.Object, db); + + await Assert.ThrowsAsync(() => svc.DeactivateAsync("missing")); + } + + // ── ResetPasswordAsync ─────────────────────────────────────────────────── + + [Fact] + public async Task ResetPasswordAsync_ReturnsNewTempPassword() + { + using var db = BuildDb(); + var user = new AppUser { Id = "u1", UserName = "a@b.com", Email = "a@b.com" }; + var mgr = BuildUserManager(findResult: user); + var svc = new UserManagementService(mgr.Object, db); + + var pwd = await svc.ResetPasswordAsync("u1"); + + Assert.Equal(12, pwd.Length); + } +} diff --git a/API/ROLAC.API/Services/IUserManagementService.cs b/API/ROLAC.API/Services/IUserManagementService.cs new file mode 100644 index 0000000..f303093 --- /dev/null +++ b/API/ROLAC.API/Services/IUserManagementService.cs @@ -0,0 +1,14 @@ +using ROLAC.API.DTOs.Shared; +using ROLAC.API.DTOs.Users; + +namespace ROLAC.API.Services; + +public interface IUserManagementService +{ + Task> GetPagedAsync(int page, int pageSize, string? search); + Task GetByIdAsync(string id); + Task CreateAsync(CreateUserRequest request); + Task UpdateAsync(string id, UpdateUserRequest request); + Task DeactivateAsync(string id); + Task ResetPasswordAsync(string id); +} diff --git a/API/ROLAC.API/Services/UserManagementService.cs b/API/ROLAC.API/Services/UserManagementService.cs new file mode 100644 index 0000000..8074d50 --- /dev/null +++ b/API/ROLAC.API/Services/UserManagementService.cs @@ -0,0 +1,232 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Shared; +using ROLAC.API.DTOs.Users; +using ROLAC.API.Entities; + +namespace ROLAC.API.Services; + +public class UserManagementService : IUserManagementService +{ + private readonly UserManager _userManager; + private readonly AppDbContext _db; + + public UserManagementService(UserManager userManager, AppDbContext db) + { + _userManager = userManager; + _db = db; + } + + // ── GetPaged ───────────────────────────────────────────────────────────── + + public async Task> GetPagedAsync( + int page, int pageSize, string? search) + { + var query = from u in _userManager.Users + join m in _db.Members.IgnoreQueryFilters() + on u.MemberId equals (int?)m.Id into mg + from m in mg.DefaultIfEmpty() + select new { u, m }; + + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim().ToLower(); + query = query.Where(x => + x.u.Email!.ToLower().Contains(s) || + (x.m != null && ( + x.m.FirstName_en.ToLower().Contains(s) || + x.m.LastName_en.ToLower().Contains(s) || + (x.m.NickName != null && x.m.NickName.ToLower().Contains(s))))); + } + + var total = await query.CountAsync(); + var rows = await query + .OrderBy(x => x.u.Email) + .Skip((page - 1) * pageSize).Take(pageSize) + .Select(x => new + { + x.u.Id, x.u.Email, x.u.MemberId, x.u.IsActive, + x.u.LanguagePreference, x.u.LastLoginAt, x.u.CreatedAt, + MemberDisplayName = x.m != null + ? (x.m.NickName ?? x.m.FirstName_en) + " " + x.m.LastName_en + : (string?)null, + }) + .ToListAsync(); + + // Batch-load roles + var userIds = rows.Select(r => r.Id).ToList(); + var roleMap = await ( + from ur in _db.UserRoles + join r in _db.Roles on ur.RoleId equals r.Id + where userIds.Contains(ur.UserId) + select new { ur.UserId, r.Name } + ).ToListAsync(); + + var rolesByUser = roleMap + .GroupBy(x => x.UserId) + .ToDictionary(g => g.Key, g => g.Select(x => x.Name!).ToList()); + + var items = rows.Select(r => new UserListItemDto + { + Id = r.Id, + Email = r.Email ?? "", + MemberId = r.MemberId, + MemberDisplayName = r.MemberDisplayName, + IsActive = r.IsActive, + LanguagePreference = r.LanguagePreference, + LastLoginAt = r.LastLoginAt, + CreatedAt = r.CreatedAt, + Roles = rolesByUser.GetValueOrDefault(r.Id, []), + }).ToList(); + + return new PagedResult + { Items = items, TotalCount = total, Page = page, PageSize = pageSize }; + } + + // ── GetById ────────────────────────────────────────────────────────────── + + public async Task GetByIdAsync(string id) + { + var user = await _userManager.FindByIdAsync(id); + if (user is null) return null; + + var roles = await _userManager.GetRolesAsync(user); + var memberName = user.MemberId.HasValue + ? await _db.Members.IgnoreQueryFilters() + .Where(m => m.Id == user.MemberId) + .Select(m => (m.NickName ?? m.FirstName_en) + " " + m.LastName_en) + .FirstOrDefaultAsync() + : null; + + return new UserDto + { + Id = user.Id, + Email = user.Email ?? "", + MemberId = user.MemberId, + MemberDisplayName = memberName, + Roles = roles.ToList(), + IsActive = user.IsActive, + LanguagePreference = user.LanguagePreference, + LastLoginAt = user.LastLoginAt, + CreatedAt = user.CreatedAt, + }; + } + + // ── Create ─────────────────────────────────────────────────────────────── + + public async Task CreateAsync(CreateUserRequest request) + { + // Validate Member exists (FindAsync bypasses query filter — intentional) + var member = await _db.Members.FindAsync(request.MemberId) + ?? throw new InvalidOperationException( + $"Member {request.MemberId} does not exist."); + + // One user per member + if (_userManager.Users.Any(u => u.MemberId == request.MemberId)) + throw new InvalidOperationException( + "This member already has a user account."); + + // Unique email + if (await _userManager.FindByEmailAsync(request.Email) is not null) + throw new InvalidOperationException( + $"Email '{request.Email}' is already in use."); + + var tempPassword = GenerateTempPassword(); + var user = new AppUser + { + UserName = request.Email, + Email = request.Email, + EmailConfirmed = true, + MemberId = request.MemberId, + LanguagePreference = request.LanguagePreference, + IsActive = true, + CreatedAt = DateTime.UtcNow, + }; + + var result = await _userManager.CreateAsync(user, tempPassword); + if (!result.Succeeded) + throw new InvalidOperationException( + string.Join("; ", result.Errors.Select(e => e.Description))); + + await _userManager.AddToRolesAsync(user, request.Roles); + + return new CreateUserResult { UserId = user.Id, TempPassword = tempPassword }; + } + + // ── Update ─────────────────────────────────────────────────────────────── + + public async Task UpdateAsync(string id, UpdateUserRequest request) + { + var user = await _userManager.FindByIdAsync(id) + ?? throw new KeyNotFoundException($"User {id} not found."); + + user.Email = request.Email; + user.UserName = request.Email; + user.NormalizedEmail = request.Email.ToUpperInvariant(); + user.NormalizedUserName = request.Email.ToUpperInvariant(); + user.LanguagePreference = request.LanguagePreference; + user.IsActive = request.IsActive; + user.LockoutEnd = request.IsActive ? null : DateTimeOffset.MaxValue; + + var updateResult = await _userManager.UpdateAsync(user); + if (!updateResult.Succeeded) + throw new InvalidOperationException( + string.Join("; ", updateResult.Errors.Select(e => e.Description))); + + var currentRoles = await _userManager.GetRolesAsync(user); + var toRemove = currentRoles.Except(request.Roles).ToList(); + var toAdd = request.Roles.Except(currentRoles).ToList(); + if (toRemove.Count > 0) await _userManager.RemoveFromRolesAsync(user, toRemove); + if (toAdd.Count > 0) await _userManager.AddToRolesAsync(user, toAdd); + } + + // ── Deactivate ─────────────────────────────────────────────────────────── + + public async Task DeactivateAsync(string id) + { + var user = await _userManager.FindByIdAsync(id) + ?? throw new KeyNotFoundException($"User {id} not found."); + user.IsActive = false; + user.LockoutEnd = DateTimeOffset.MaxValue; + await _userManager.UpdateAsync(user); + } + + // ── ResetPassword ──────────────────────────────────────────────────────── + + public async Task ResetPasswordAsync(string id) + { + var user = await _userManager.FindByIdAsync(id) + ?? throw new KeyNotFoundException($"User {id} not found."); + + var tempPassword = GenerateTempPassword(); + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + var result = await _userManager.ResetPasswordAsync(user, token, tempPassword); + + if (!result.Succeeded) + throw new InvalidOperationException( + string.Join("; ", result.Errors.Select(e => e.Description))); + + return tempPassword; + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private static string GenerateTempPassword() + { + const string upper = "ABCDEFGHJKLMNPQRSTUVWXYZ"; + const string lower = "abcdefghjkmnpqrstuvwxyz"; + const string digits = "23456789"; + const string special = "!@#$%^"; + const string all = upper + lower + digits + special; + + var rng = new Random(); + var pw = new char[12]; + pw[0] = upper[rng.Next(upper.Length)]; + pw[1] = lower[rng.Next(lower.Length)]; + pw[2] = digits[rng.Next(digits.Length)]; + pw[3] = special[rng.Next(special.Length)]; + for (var i = 4; i < 12; i++) pw[i] = all[rng.Next(all.Length)]; + return new string(pw.OrderBy(_ => rng.Next()).ToArray()); + } +}