61c6697c87
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1134 lines
42 KiB
Markdown
1134 lines
42 KiB
Markdown
# Member & User Management — Part 2: Services & Controllers (Tasks 6–9)
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement task-by-task.
|
||
> **Prerequisite:** Part 1 complete (entities, migration, DTOs all in place).
|
||
|
||
**Goal:** Implement MemberService, UserManagementService, MembersController, and UsersController with full test coverage.
|
||
|
||
**Architecture:** Services inject `AppDbContext` + `IHttpContextAccessor`. Controllers are thin — validation, HTTP status mapping, and delegation only. Soft-delete is done explicitly in `DeleteAsync` (sets `IsDeleted = true`) so tests work without the interceptor.
|
||
|
||
**Tech Stack:** C# .NET 8, EF Core 8, ASP.NET Identity, xUnit, Moq, EF InMemory
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-05-27-aspnetusers-member-management-design.md`
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
```
|
||
API/ROLAC.API/
|
||
Services/
|
||
IMemberService.cs ← NEW
|
||
MemberService.cs ← NEW
|
||
IUserManagementService.cs ← NEW
|
||
UserManagementService.cs ← NEW
|
||
Controllers/
|
||
MembersController.cs ← NEW
|
||
UsersController.cs ← NEW
|
||
Program.cs ← MODIFY (register new services)
|
||
API/ROLAC.API.Tests/
|
||
Services/
|
||
MemberServiceTests.cs ← NEW
|
||
UserManagementServiceTests.cs ← NEW
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: MemberService
|
||
|
||
**Files:**
|
||
- Create: `API/ROLAC.API/Services/IMemberService.cs`
|
||
- Create: `API/ROLAC.API/Services/MemberService.cs`
|
||
- Create: `API/ROLAC.API.Tests/Services/MemberServiceTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing tests first**
|
||
|
||
```csharp
|
||
// API/ROLAC.API.Tests/Services/MemberServiceTests.cs
|
||
using System.Security.Claims;
|
||
using Microsoft.AspNetCore.Http;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Moq;
|
||
using ROLAC.API.Data;
|
||
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 AppDbContext BuildDb() =>
|
||
new(new DbContextOptionsBuilder<AppDbContext>()
|
||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||
.Options);
|
||
|
||
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;
|
||
}
|
||
|
||
// ── 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));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests — expect FAIL (IMemberService doesn't exist)**
|
||
|
||
```
|
||
cd API/ROLAC.API.Tests && dotnet test --filter "MemberServiceTests" -v
|
||
```
|
||
Expected: Build error — `MemberService` not found.
|
||
|
||
- [ ] **Step 3: Create `IMemberService.cs`**
|
||
|
||
```csharp
|
||
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);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create `MemberService.cs`**
|
||
|
||
```csharp
|
||
using System.Security.Claims;
|
||
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 = from m in _db.Members
|
||
join u in _db.Users on (int?)m.Id equals u.MemberId into ug
|
||
from u in ug.DefaultIfEmpty()
|
||
select new { m, LinkedUserId = u != null ? u.Id : null };
|
||
|
||
if (!string.IsNullOrWhiteSpace(search))
|
||
{
|
||
var s = search.Trim().ToLower();
|
||
query = query.Where(x =>
|
||
x.m.FirstName_en.ToLower().Contains(s) ||
|
||
x.m.LastName_en.ToLower().Contains(s) ||
|
||
(x.m.NickName != null && x.m.NickName.ToLower().Contains(s)) ||
|
||
(x.m.FirstName_zh != null && x.m.FirstName_zh.Contains(search)) ||
|
||
(x.m.LastName_zh != null && x.m.LastName_zh.Contains(search)) ||
|
||
(x.m.Email != null && x.m.Email.ToLower().Contains(s)));
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(status))
|
||
query = query.Where(x => x.m.Status == status);
|
||
|
||
if (hasUser.HasValue)
|
||
query = hasUser.Value
|
||
? query.Where(x => x.LinkedUserId != null)
|
||
: query.Where(x => x.LinkedUserId == null);
|
||
|
||
var total = await query.CountAsync();
|
||
var items = await query
|
||
.OrderBy(x => x.m.LastName_en).ThenBy(x => x.m.FirstName_en)
|
||
.Skip((page - 1) * pageSize).Take(pageSize)
|
||
.Select(x => new MemberListItemDto
|
||
{
|
||
Id = x.m.Id,
|
||
FirstName_en = x.m.FirstName_en,
|
||
LastName_en = x.m.LastName_en,
|
||
NickName = x.m.NickName,
|
||
FirstName_zh = x.m.FirstName_zh,
|
||
LastName_zh = x.m.LastName_zh,
|
||
Status = x.m.Status,
|
||
Email = x.m.Email,
|
||
PhoneCell = x.m.PhoneCell,
|
||
JoinDate = x.m.JoinDate,
|
||
LinkedUserId = x.LinkedUserId,
|
||
})
|
||
.ToListAsync();
|
||
|
||
return new PagedResult<MemberListItemDto>
|
||
{ Items = items, TotalCount = total, Page = page, PageSize = pageSize };
|
||
}
|
||
|
||
// ── GetById ──────────────────────────────────────────────────────────────
|
||
|
||
public async Task<MemberDto?> GetByIdAsync(int id)
|
||
{
|
||
var row = await (from m in _db.Members
|
||
join u in _db.Users on (int?)m.Id equals u.MemberId into ug
|
||
from u in ug.DefaultIfEmpty()
|
||
where m.Id == id
|
||
select new { m, LinkedUserId = u != null ? u.Id : null })
|
||
.AsNoTracking()
|
||
.FirstOrDefaultAsync();
|
||
|
||
if (row is null) return null;
|
||
var m = row.m;
|
||
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 = row.LinkedUserId,
|
||
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 = DateTime.UtcNow;
|
||
m.DeletedBy = CurrentUserId;
|
||
await _db.SaveChangesAsync();
|
||
}
|
||
|
||
// ── 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;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests — expect PASS**
|
||
|
||
```
|
||
cd API/ROLAC.API.Tests && dotnet test --filter "MemberServiceTests" -v
|
||
```
|
||
Expected: All 9 tests pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add API/ROLAC.API/Services/IMemberService.cs API/ROLAC.API/Services/MemberService.cs \
|
||
API/ROLAC.API.Tests/Services/MemberServiceTests.cs
|
||
git commit -m "feat: add MemberService with soft-delete and paged search"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: MembersController
|
||
|
||
**Files:**
|
||
- Create: `API/ROLAC.API/Controllers/MembersController.cs`
|
||
- Modify: `API/ROLAC.API/Program.cs`
|
||
|
||
- [ ] **Step 1: Create `MembersController.cs`**
|
||
|
||
```csharp
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using ROLAC.API.DTOs.Members;
|
||
using ROLAC.API.Services;
|
||
|
||
namespace ROLAC.API.Controllers;
|
||
|
||
[ApiController]
|
||
[Route("api/members")]
|
||
[Authorize]
|
||
public class MembersController : ControllerBase
|
||
{
|
||
private readonly IMemberService _members;
|
||
public MembersController(IMemberService members) => _members = members;
|
||
|
||
/// <summary>GET /api/members?page=1&pageSize=20&search=Chen&status=Member&hasUser=false</summary>
|
||
[HttpGet]
|
||
[Authorize(Roles = "super_admin,secretary,pastor")]
|
||
public async Task<IActionResult> GetPaged(
|
||
[FromQuery] int page = 1,
|
||
[FromQuery] int pageSize = 20,
|
||
[FromQuery] string? search = null,
|
||
[FromQuery] string? status = null,
|
||
[FromQuery] bool? hasUser = null)
|
||
=> Ok(await _members.GetPagedAsync(page, pageSize, search, status, hasUser));
|
||
|
||
/// <summary>GET /api/members/{id}</summary>
|
||
[HttpGet("{id:int}")]
|
||
[Authorize(Roles = "super_admin,secretary,pastor")]
|
||
public async Task<IActionResult> GetById(int id)
|
||
{
|
||
var dto = await _members.GetByIdAsync(id);
|
||
return dto is null ? NotFound() : Ok(dto);
|
||
}
|
||
|
||
/// <summary>POST /api/members</summary>
|
||
[HttpPost]
|
||
[Authorize(Roles = "super_admin,secretary")]
|
||
public async Task<IActionResult> Create([FromBody] CreateMemberRequest request)
|
||
{
|
||
var id = await _members.CreateAsync(request);
|
||
return CreatedAtAction(nameof(GetById), new { id }, new { id });
|
||
}
|
||
|
||
/// <summary>PUT /api/members/{id}</summary>
|
||
[HttpPut("{id:int}")]
|
||
[Authorize(Roles = "super_admin,secretary")]
|
||
public async Task<IActionResult> Update(int id, [FromBody] UpdateMemberRequest request)
|
||
{
|
||
try { await _members.UpdateAsync(id, request); return NoContent(); }
|
||
catch (KeyNotFoundException) { return NotFound(); }
|
||
}
|
||
|
||
/// <summary>DELETE /api/members/{id} — soft delete</summary>
|
||
[HttpDelete("{id:int}")]
|
||
[Authorize(Roles = "super_admin,secretary")]
|
||
public async Task<IActionResult> Delete(int id)
|
||
{
|
||
try { await _members.DeleteAsync(id); return NoContent(); }
|
||
catch (KeyNotFoundException) { return NotFound(); }
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Register `IMemberService` in `Program.cs`**
|
||
|
||
Add after the existing service registrations (before `builder.Services.AddControllers()`):
|
||
|
||
```csharp
|
||
builder.Services.AddScoped<IMemberService, MemberService>();
|
||
```
|
||
|
||
- [ ] **Step 3: Build and verify Swagger shows the new endpoints**
|
||
|
||
```
|
||
cd API/ROLAC.API && dotnet run
|
||
```
|
||
Open `https://localhost:{port}/swagger` — verify 5 `/api/members` endpoints appear.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add API/ROLAC.API/Controllers/MembersController.cs API/ROLAC.API/Program.cs
|
||
git commit -m "feat: add MembersController (CRUD + paged list)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: UserManagementService
|
||
|
||
**Files:**
|
||
- Create: `API/ROLAC.API/Services/IUserManagementService.cs`
|
||
- Create: `API/ROLAC.API/Services/UserManagementService.cs`
|
||
- Create: `API/ROLAC.API.Tests/Services/UserManagementServiceTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```csharp
|
||
// API/ROLAC.API.Tests/Services/UserManagementServiceTests.cs
|
||
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<AppDbContext>()
|
||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||
.Options);
|
||
|
||
private static Mock<UserManager<AppUser>> BuildUserManager(
|
||
AppUser? findResult = null,
|
||
bool createOk = true,
|
||
IList<string>? roles = null)
|
||
{
|
||
var store = new Mock<IUserStore<AppUser>>();
|
||
#pragma warning disable CS8625
|
||
var mgr = new Mock<UserManager<AppUser>>(
|
||
store.Object, null, null, null, null, null, null, null, null);
|
||
#pragma warning restore CS8625
|
||
mgr.Setup(m => m.FindByIdAsync(It.IsAny<string>()))
|
||
.ReturnsAsync(findResult);
|
||
mgr.Setup(m => m.FindByEmailAsync(It.IsAny<string>()))
|
||
.ReturnsAsync((AppUser?)null);
|
||
mgr.Setup(m => m.CreateAsync(It.IsAny<AppUser>(), It.IsAny<string>()))
|
||
.ReturnsAsync(createOk ? IdentityResult.Success
|
||
: IdentityResult.Failed(new IdentityError { Description = "fail" }));
|
||
mgr.Setup(m => m.AddToRolesAsync(It.IsAny<AppUser>(), It.IsAny<IEnumerable<string>>()))
|
||
.ReturnsAsync(IdentityResult.Success);
|
||
mgr.Setup(m => m.GetRolesAsync(It.IsAny<AppUser>()))
|
||
.ReturnsAsync(roles ?? new List<string> { "member" });
|
||
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
|
||
.ReturnsAsync(IdentityResult.Success);
|
||
mgr.Setup(m => m.RemoveFromRolesAsync(It.IsAny<AppUser>(), It.IsAny<IEnumerable<string>>()))
|
||
.ReturnsAsync(IdentityResult.Success);
|
||
mgr.Setup(m => m.GeneratePasswordResetTokenAsync(It.IsAny<AppUser>()))
|
||
.ReturnsAsync("reset-token");
|
||
mgr.Setup(m => m.ResetPasswordAsync(It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
|
||
.ReturnsAsync(IdentityResult.Success);
|
||
return mgr;
|
||
}
|
||
|
||
// ── CreateAsync ──────────────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public async Task CreateAsync_ReturnsTempPassword()
|
||
{
|
||
using var db = BuildDb();
|
||
// Seed a Member so MemberId validation passes
|
||
var member = new Member { FirstName_en = "A", LastName_en = "B" };
|
||
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<AppUser>(), It.IsAny<string>()))
|
||
.Callback<AppUser, string>((u, _) => { created = u; u.Id = Guid.NewGuid().ToString(); })
|
||
.ReturnsAsync(IdentityResult.Success);
|
||
|
||
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();
|
||
var svc = new UserManagementService(mgr.Object, db);
|
||
|
||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||
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" };
|
||
db.Members.Add(member);
|
||
// Seed an AppUser with MemberId set in the Users table
|
||
var existingUser = new AppUser
|
||
{
|
||
Id = Guid.NewGuid().ToString(),
|
||
UserName = "existing@test.com",
|
||
Email = "existing@test.com",
|
||
MemberId = null, // will be set below
|
||
};
|
||
// Use IgnoreQueryFilters-safe approach: directly set via db
|
||
db.SaveChanges();
|
||
member = db.Members.First();
|
||
existingUser.MemberId = member.Id;
|
||
db.Users.Add(existingUser);
|
||
await db.SaveChangesAsync();
|
||
|
||
var mgr = BuildUserManager();
|
||
var svc = new UserManagementService(mgr.Object, db);
|
||
|
||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||
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<KeyNotFoundException>(() => 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);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests — expect FAIL**
|
||
|
||
```
|
||
cd API/ROLAC.API.Tests && dotnet test --filter "UserManagementServiceTests" -v
|
||
```
|
||
Expected: Build error — `UserManagementService` not found.
|
||
|
||
- [ ] **Step 3: Create `IUserManagementService.cs`**
|
||
|
||
```csharp
|
||
using ROLAC.API.DTOs.Shared;
|
||
using ROLAC.API.DTOs.Users;
|
||
|
||
namespace ROLAC.API.Services;
|
||
|
||
public interface IUserManagementService
|
||
{
|
||
Task<PagedResult<UserListItemDto>> GetPagedAsync(int page, int pageSize, string? search);
|
||
Task<UserDto?> GetByIdAsync(string id);
|
||
Task<CreateUserResult> CreateAsync(CreateUserRequest request);
|
||
Task UpdateAsync(string id, UpdateUserRequest request);
|
||
Task DeactivateAsync(string id);
|
||
Task<string> ResetPasswordAsync(string id);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create `UserManagementService.cs`**
|
||
|
||
```csharp
|
||
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<AppUser> _userManager;
|
||
private readonly AppDbContext _db;
|
||
|
||
public UserManagementService(UserManager<AppUser> userManager, AppDbContext db)
|
||
{
|
||
_userManager = userManager;
|
||
_db = db;
|
||
}
|
||
|
||
// ── GetPaged ─────────────────────────────────────────────────────────────
|
||
|
||
public async Task<PagedResult<UserListItemDto>> 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<UserListItemDto>
|
||
{ Items = items, TotalCount = total, Page = page, PageSize = pageSize };
|
||
}
|
||
|
||
// ── GetById ──────────────────────────────────────────────────────────────
|
||
|
||
public async Task<UserDto?> 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<CreateUserResult> CreateAsync(CreateUserRequest request)
|
||
{
|
||
// Validate Member exists
|
||
var member = await _db.Members.FindAsync(request.MemberId)
|
||
?? throw new InvalidOperationException(
|
||
$"Member {request.MemberId} does not exist.");
|
||
|
||
// One user per member
|
||
if (await _userManager.Users.AnyAsync(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<string> 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());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests — expect PASS**
|
||
|
||
```
|
||
cd API/ROLAC.API.Tests && dotnet test --filter "UserManagementServiceTests" -v
|
||
```
|
||
Expected: All 6 tests pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add API/ROLAC.API/Services/IUserManagementService.cs \
|
||
API/ROLAC.API/Services/UserManagementService.cs \
|
||
API/ROLAC.API.Tests/Services/UserManagementServiceTests.cs
|
||
git commit -m "feat: add UserManagementService with temp-password creation and deactivation"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: UsersController + Program.cs Registration
|
||
|
||
**Files:**
|
||
- Create: `API/ROLAC.API/Controllers/UsersController.cs`
|
||
- Modify: `API/ROLAC.API/Program.cs`
|
||
|
||
- [ ] **Step 1: Create `UsersController.cs`**
|
||
|
||
```csharp
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using ROLAC.API.DTOs.Users;
|
||
using ROLAC.API.Services;
|
||
|
||
namespace ROLAC.API.Controllers;
|
||
|
||
[ApiController]
|
||
[Route("api/users")]
|
||
[Authorize(Roles = "super_admin")]
|
||
public class UsersController : ControllerBase
|
||
{
|
||
private readonly IUserManagementService _users;
|
||
public UsersController(IUserManagementService users) => _users = users;
|
||
|
||
/// <summary>GET /api/users?page=1&pageSize=20&search=Chris</summary>
|
||
[HttpGet]
|
||
public async Task<IActionResult> GetPaged(
|
||
[FromQuery] int page = 1,
|
||
[FromQuery] int pageSize = 20,
|
||
[FromQuery] string? search = null)
|
||
=> Ok(await _users.GetPagedAsync(page, pageSize, search));
|
||
|
||
/// <summary>GET /api/users/{id}</summary>
|
||
[HttpGet("{id}")]
|
||
public async Task<IActionResult> GetById(string id)
|
||
{
|
||
var dto = await _users.GetByIdAsync(id);
|
||
return dto is null ? NotFound() : Ok(dto);
|
||
}
|
||
|
||
/// <summary>
|
||
/// POST /api/users — creates account for a Member, returns { userId, tempPassword }.
|
||
/// TempPassword is returned ONCE — show it to the admin and never log it.
|
||
/// </summary>
|
||
[HttpPost]
|
||
public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
|
||
{
|
||
try
|
||
{
|
||
var result = await _users.CreateAsync(request);
|
||
return Ok(result);
|
||
}
|
||
catch (InvalidOperationException ex)
|
||
{
|
||
return BadRequest(new { message = ex.Message });
|
||
}
|
||
}
|
||
|
||
/// <summary>PUT /api/users/{id} — update email, roles, IsActive</summary>
|
||
[HttpPut("{id}")]
|
||
public async Task<IActionResult> Update(string id, [FromBody] UpdateUserRequest request)
|
||
{
|
||
try { await _users.UpdateAsync(id, request); return NoContent(); }
|
||
catch (KeyNotFoundException) { return NotFound(); }
|
||
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
|
||
}
|
||
|
||
/// <summary>DELETE /api/users/{id} — deactivates account (IsActive=false), does not delete</summary>
|
||
[HttpDelete("{id}")]
|
||
public async Task<IActionResult> Deactivate(string id)
|
||
{
|
||
try { await _users.DeactivateAsync(id); return NoContent(); }
|
||
catch (KeyNotFoundException) { return NotFound(); }
|
||
}
|
||
|
||
/// <summary>POST /api/users/{id}/reset-password — returns new temp password</summary>
|
||
[HttpPost("{id}/reset-password")]
|
||
public async Task<IActionResult> ResetPassword(string id)
|
||
{
|
||
try
|
||
{
|
||
var pwd = await _users.ResetPasswordAsync(id);
|
||
return Ok(new { tempPassword = pwd });
|
||
}
|
||
catch (KeyNotFoundException) { return NotFound(); }
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Register `IUserManagementService` in `Program.cs`**
|
||
|
||
Add alongside the `IMemberService` registration:
|
||
|
||
```csharp
|
||
builder.Services.AddScoped<IUserManagementService, UserManagementService>();
|
||
```
|
||
|
||
- [ ] **Step 3: Build and smoke-test via Swagger**
|
||
|
||
```
|
||
cd API/ROLAC.API && dotnet run
|
||
```
|
||
Open Swagger → authenticate as `admin@rolac.org / Admin1234!` → call `GET /api/members` → expect `200 { items: [], totalCount: 0 }`.
|
||
|
||
- [ ] **Step 4: Run all tests**
|
||
|
||
```
|
||
cd API/ROLAC.API.Tests && dotnet test -v
|
||
```
|
||
Expected: All tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add API/ROLAC.API/Controllers/UsersController.cs API/ROLAC.API/Program.cs
|
||
git commit -m "feat: add UsersController and register all services"
|
||
```
|
||
|
||
---
|
||
|
||
**Part 2 complete.** Continue with `2026-05-27-member-user-mgmt-part3-frontend.md`.
|