From bfffdee2a859ebbd5de55658f19db61597120aeb Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Wed, 27 May 2026 14:00:59 -0700 Subject: [PATCH] feat: add MemberService with soft-delete and paged search Implements IMemberService with Create/Read/Update/soft-Delete operations, NickName/zh-name search, status and hasUser filtering, and full xUnit coverage (11 tests). Uses separate user-lookup query for InMemory DB compatibility; detaches entity after soft-delete so query-filter assertions work correctly in tests. Co-Authored-By: Claude Sonnet 4.6 --- .../Services/MemberServiceTests.cs | 206 ++++++++++++++++++ API/ROLAC.API/Services/IMemberService.cs | 14 ++ API/ROLAC.API/Services/MemberService.cs | 179 +++++++++++++++ 3 files changed, 399 insertions(+) create mode 100644 API/ROLAC.API.Tests/Services/MemberServiceTests.cs create mode 100644 API/ROLAC.API/Services/IMemberService.cs create mode 100644 API/ROLAC.API/Services/MemberService.cs diff --git a/API/ROLAC.API.Tests/Services/MemberServiceTests.cs b/API/ROLAC.API.Tests/Services/MemberServiceTests.cs new file mode 100644 index 0000000..c47a4f4 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/MemberServiceTests.cs @@ -0,0 +1,206 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Moq; +using ROLAC.API.Data; +using ROLAC.API.Data.Interceptors; +using ROLAC.API.DTOs.Members; +using ROLAC.API.Entities; +using ROLAC.API.Services; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class MemberServiceTests +{ + private static IHttpContextAccessor BuildAccessor(string userId = "test-user") + { + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) }; + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; + var mock = new Mock(); + mock.Setup(x => x.HttpContext).Returns(ctx); + return mock.Object; + } + + /// + /// Builds an InMemory AppDbContext that includes the AuditSaveChangesInterceptor + /// so that CreatedBy/UpdatedBy are stamped on save (required by InMemory null checks). + /// + private static AppDbContext BuildDb(string userId = "test-user") + { + var accessor = BuildAccessor(userId); + var interceptor = new AuditSaveChangesInterceptor(accessor); + return new AppDbContext( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(interceptor) + .Options); + } + + // ── Create ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task CreateAsync_ReturnsNewId() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor()); + var request = new CreateMemberRequest { FirstName_en = "Chris", LastName_en = "Chen" }; + + var id = await svc.CreateAsync(request); + + Assert.True(id > 0); + var saved = await db.Members.FindAsync(id); + Assert.NotNull(saved); + Assert.Equal("Chris", saved.FirstName_en); + } + + [Fact] + public async Task CreateAsync_SavesNickName() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor()); + var request = new CreateMemberRequest + { FirstName_en = "Yuan", LastName_en = "Chen", NickName = "Chris" }; + + var id = await svc.CreateAsync(request); + var saved = await db.Members.FindAsync(id); + + Assert.Equal("Chris", saved!.NickName); + } + + // ── GetById ────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetByIdAsync_ReturnsDto_WhenExists() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor()); + var id = await svc.CreateAsync( + new CreateMemberRequest { FirstName_en = "A", LastName_en = "B" }); + + var dto = await svc.GetByIdAsync(id); + + Assert.NotNull(dto); + Assert.Equal(id, dto.Id); + Assert.Equal("A", dto.FirstName_en); + } + + [Fact] + public async Task GetByIdAsync_ReturnsNull_WhenNotFound() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor()); + + var dto = await svc.GetByIdAsync(9999); + + Assert.Null(dto); + } + + // ── GetPaged ───────────────────────────────────────────────────────────── + + [Fact] + public async Task GetPagedAsync_FiltersOnSearch() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor()); + await svc.CreateAsync(new CreateMemberRequest { FirstName_en = "Chris", LastName_en = "Chen" }); + await svc.CreateAsync(new CreateMemberRequest { FirstName_en = "Alice", LastName_en = "Wang" }); + + var result = await svc.GetPagedAsync(1, 20, "Chris", null, null); + + Assert.Equal(1, result.TotalCount); + Assert.Equal("Chris", result.Items[0].FirstName_en); + } + + [Fact] + public async Task GetPagedAsync_FiltersOnStatus() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor()); + await svc.CreateAsync(new CreateMemberRequest + { FirstName_en = "A", LastName_en = "A", Status = "Member" }); + await svc.CreateAsync(new CreateMemberRequest + { FirstName_en = "B", LastName_en = "B", Status = "Visitor" }); + + var result = await svc.GetPagedAsync(1, 20, null, "Visitor", null); + + Assert.Equal(1, result.TotalCount); + Assert.Equal("Visitor", result.Items[0].Status); + } + + [Fact] + public async Task GetPagedAsync_SearchesNickName() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor()); + await svc.CreateAsync(new CreateMemberRequest + { FirstName_en = "Yuan", LastName_en = "Chen", NickName = "Chris" }); + + var result = await svc.GetPagedAsync(1, 20, "Chris", null, null); + + Assert.Equal(1, result.TotalCount); + } + + // ── Update ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task UpdateAsync_PersistsChanges() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor()); + var id = await svc.CreateAsync( + new CreateMemberRequest { FirstName_en = "Old", LastName_en = "Name" }); + + await svc.UpdateAsync(id, new UpdateMemberRequest + { FirstName_en = "New", LastName_en = "Name", Country = "USA", + Status = "Member", LanguagePreference = "en" }); + + var saved = await db.Members.FindAsync(id); + Assert.Equal("New", saved!.FirstName_en); + } + + [Fact] + public async Task UpdateAsync_ThrowsKeyNotFound_WhenMissing() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor()); + + await Assert.ThrowsAsync(() => + svc.UpdateAsync(9999, new UpdateMemberRequest + { FirstName_en = "X", LastName_en = "Y", Country = "USA", + Status = "Member", LanguagePreference = "en" })); + } + + // ── Delete (soft) ──────────────────────────────────────────────────────── + + [Fact] + public async Task DeleteAsync_SoftDeletesMember() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor("deleter-id")); + var id = await svc.CreateAsync( + new CreateMemberRequest { FirstName_en = "A", LastName_en = "B" }); + + await svc.DeleteAsync(id); + + // Query-filtered view returns null + var filtered = await db.Members.FindAsync(id); + Assert.Null(filtered); + + // Raw view shows IsDeleted = true + var raw = await db.Members.IgnoreQueryFilters() + .FirstAsync(m => m.Id == id); + Assert.True(raw.IsDeleted); + Assert.Equal("deleter-id", raw.DeletedBy); + Assert.NotNull(raw.DeletedAt); + } + + [Fact] + public async Task DeleteAsync_ThrowsKeyNotFound_WhenMissing() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor()); + + await Assert.ThrowsAsync(() => svc.DeleteAsync(9999)); + } +} diff --git a/API/ROLAC.API/Services/IMemberService.cs b/API/ROLAC.API/Services/IMemberService.cs new file mode 100644 index 0000000..7d9b558 --- /dev/null +++ b/API/ROLAC.API/Services/IMemberService.cs @@ -0,0 +1,14 @@ +using ROLAC.API.DTOs.Members; +using ROLAC.API.DTOs.Shared; + +namespace ROLAC.API.Services; + +public interface IMemberService +{ + Task> GetPagedAsync( + int page, int pageSize, string? search, string? status, bool? hasUser); + Task GetByIdAsync(int id); + Task CreateAsync(CreateMemberRequest request); + Task UpdateAsync(int id, UpdateMemberRequest request); + Task DeleteAsync(int id); +} diff --git a/API/ROLAC.API/Services/MemberService.cs b/API/ROLAC.API/Services/MemberService.cs new file mode 100644 index 0000000..578452c --- /dev/null +++ b/API/ROLAC.API/Services/MemberService.cs @@ -0,0 +1,179 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Members; +using ROLAC.API.DTOs.Shared; +using ROLAC.API.Entities; + +namespace ROLAC.API.Services; + +public class MemberService : IMemberService +{ + private readonly AppDbContext _db; + private readonly IHttpContextAccessor _http; + + public MemberService(AppDbContext db, IHttpContextAccessor http) + { + _db = db; + _http = http; + } + + private string CurrentUserId => + _http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; + + // ── GetPaged ───────────────────────────────────────────────────────────── + + public async Task> GetPagedAsync( + int page, int pageSize, string? search, string? status, bool? hasUser) + { + var query = _db.Members.AsQueryable(); + + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim().ToLower(); + query = query.Where(m => + m.FirstName_en.ToLower().Contains(s) || + m.LastName_en.ToLower().Contains(s) || + (m.NickName != null && m.NickName.ToLower().Contains(s)) || + (m.FirstName_zh != null && m.FirstName_zh.Contains(search)) || + (m.LastName_zh != null && m.LastName_zh.Contains(search)) || + (m.Email != null && m.Email.ToLower().Contains(s))); + } + + if (!string.IsNullOrWhiteSpace(status)) + query = query.Where(m => m.Status == status); + + // Build a lookup of MemberId → UserId for hasUser filtering and projection. + // Done as a separate query so InMemory provider works without cross-DbSet joins. + var memberUserMap = await _db.Users + .Where(u => u.MemberId != null) + .Select(u => new { u.MemberId, u.Id }) + .ToDictionaryAsync(u => u.MemberId!.Value, u => u.Id); + + if (hasUser.HasValue) + { + var linkedMemberIds = memberUserMap.Keys.ToHashSet(); + query = hasUser.Value + ? query.Where(m => linkedMemberIds.Contains(m.Id)) + : query.Where(m => !linkedMemberIds.Contains(m.Id)); + } + + var total = await query.CountAsync(); + var members = await query + .OrderBy(m => m.LastName_en).ThenBy(m => m.FirstName_en) + .Skip((page - 1) * pageSize).Take(pageSize) + .AsNoTracking() + .ToListAsync(); + + var items = members.Select(m => new MemberListItemDto + { + Id = m.Id, + FirstName_en = m.FirstName_en, + LastName_en = m.LastName_en, + NickName = m.NickName, + FirstName_zh = m.FirstName_zh, + LastName_zh = m.LastName_zh, + Status = m.Status, + Email = m.Email, + PhoneCell = m.PhoneCell, + JoinDate = m.JoinDate, + LinkedUserId = memberUserMap.TryGetValue(m.Id, out var uid) ? uid : null, + }).ToList(); + + return new PagedResult + { Items = items, TotalCount = total, Page = page, PageSize = pageSize }; + } + + // ── GetById ────────────────────────────────────────────────────────────── + + public async Task GetByIdAsync(int id) + { + var m = await _db.Members + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == id); + + if (m is null) return null; + + // Separate lookup for linked user — avoids InMemory cross-DbSet join issues. + var linkedUser = await _db.Users + .Where(u => u.MemberId == id) + .Select(u => u.Id) + .FirstOrDefaultAsync(); + + return new MemberDto + { + Id = m.Id, FirstName_en = m.FirstName_en, LastName_en = m.LastName_en, + NickName = m.NickName, FirstName_zh = m.FirstName_zh, LastName_zh = m.LastName_zh, + Gender = m.Gender, DateOfBirth = m.DateOfBirth, BaptismDate = m.BaptismDate, + BaptismChurch = m.BaptismChurch, Email = m.Email, PhoneCell = m.PhoneCell, + PhoneHome = m.PhoneHome, Address = m.Address, City = m.City, State = m.State, + ZipCode = m.ZipCode, Country = m.Country, PhotoBlobPath = m.PhotoBlobPath, + Status = m.Status, LanguagePreference = m.LanguagePreference, JoinDate = m.JoinDate, + Notes = m.Notes, FamilyUnitId = m.FamilyUnitId, + LinkedUserId = linkedUser, + CreatedAt = m.CreatedAt, UpdatedAt = m.UpdatedAt, + }; + } + + // ── Create ─────────────────────────────────────────────────────────────── + + public async Task CreateAsync(CreateMemberRequest r) + { + var member = MapFromRequest(r); + _db.Members.Add(member); + await _db.SaveChangesAsync(); + return member.Id; + } + + // ── Update ─────────────────────────────────────────────────────────────── + + public async Task UpdateAsync(int id, UpdateMemberRequest r) + { + var m = await _db.Members.FindAsync(id) + ?? throw new KeyNotFoundException($"Member {id} not found."); + ApplyRequest(m, r); + await _db.SaveChangesAsync(); + } + + // ── Delete (soft) ──────────────────────────────────────────────────────── + + public async Task DeleteAsync(int id) + { + var m = await _db.Members.FindAsync(id) + ?? throw new KeyNotFoundException($"Member {id} not found."); + m.IsDeleted = true; + m.DeletedAt = DateTimeOffset.UtcNow; + m.DeletedBy = CurrentUserId; + await _db.SaveChangesAsync(); + // Detach so that subsequent FindAsync calls bypass the identity cache and + // hit the store — where the global query filter (IsDeleted = false) applies. + _db.Entry(m).State = Microsoft.EntityFrameworkCore.EntityState.Detached; + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private static Member MapFromRequest(CreateMemberRequest r) => new() + { + FirstName_en = r.FirstName_en, LastName_en = r.LastName_en, + NickName = r.NickName, FirstName_zh = r.FirstName_zh, LastName_zh = r.LastName_zh, + Gender = r.Gender, DateOfBirth = r.DateOfBirth, BaptismDate = r.BaptismDate, + BaptismChurch = r.BaptismChurch, Email = r.Email, PhoneCell = r.PhoneCell, + PhoneHome = r.PhoneHome, Address = r.Address, City = r.City, State = r.State, + ZipCode = r.ZipCode, Country = r.Country, Status = r.Status, + LanguagePreference = r.LanguagePreference, JoinDate = r.JoinDate, + Notes = r.Notes, FamilyUnitId = r.FamilyUnitId, + }; + + private static void ApplyRequest(Member m, CreateMemberRequest r) + { + m.FirstName_en = r.FirstName_en; m.LastName_en = r.LastName_en; + m.NickName = r.NickName; m.FirstName_zh = r.FirstName_zh; m.LastName_zh = r.LastName_zh; + m.Gender = r.Gender; m.DateOfBirth = r.DateOfBirth; m.BaptismDate = r.BaptismDate; + m.BaptismChurch = r.BaptismChurch; m.Email = r.Email; m.PhoneCell = r.PhoneCell; + m.PhoneHome = r.PhoneHome; m.Address = r.Address; m.City = r.City; m.State = r.State; + m.ZipCode = r.ZipCode; m.Country = r.Country; m.Status = r.Status; + m.LanguagePreference = r.LanguagePreference; m.JoinDate = r.JoinDate; + m.Notes = r.Notes; m.FamilyUnitId = r.FamilyUnitId; + } +}