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 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-05-27 14:00:59 -07:00
parent 97743f6974
commit bfffdee2a8
3 changed files with 399 additions and 0 deletions
@@ -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<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return mock.Object;
}
/// <summary>
/// Builds an InMemory AppDbContext that includes the AuditSaveChangesInterceptor
/// so that CreatedBy/UpdatedBy are stamped on save (required by InMemory null checks).
/// </summary>
private static AppDbContext BuildDb(string userId = "test-user")
{
var accessor = BuildAccessor(userId);
var interceptor = new AuditSaveChangesInterceptor(accessor);
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.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<KeyNotFoundException>(() =>
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<KeyNotFoundException>(() => svc.DeleteAsync(9999));
}
}
+14
View File
@@ -0,0 +1,14 @@
using ROLAC.API.DTOs.Members;
using ROLAC.API.DTOs.Shared;
namespace ROLAC.API.Services;
public interface IMemberService
{
Task<PagedResult<MemberListItemDto>> GetPagedAsync(
int page, int pageSize, string? search, string? status, bool? hasUser);
Task<MemberDto?> GetByIdAsync(int id);
Task<int> CreateAsync(CreateMemberRequest request);
Task UpdateAsync(int id, UpdateMemberRequest request);
Task DeleteAsync(int id);
}
+179
View File
@@ -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<PagedResult<MemberListItemDto>> 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<MemberListItemDto>
{ Items = items, TotalCount = total, Page = page, PageSize = pageSize };
}
// ── GetById ──────────────────────────────────────────────────────────────
public async Task<MemberDto?> 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<int> 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;
}
}