Migrate UserManagement to a vertical slice (Features.UserManagement)

Same pattern as the Breweries migration: Service.UserManagement and the
UserAccount repository fold into Features.UserManagement, with MediatR
queries replacing UserService. UpdateUserCommand/Handler carry forward
IUserService.UpdateAsync as-is (it has no HTTP route today, and adding one
is a separate decision from this migration). Adds handler/repository test
coverage that didn't exist before, since Service.UserManagement had no
test project.
This commit is contained in:
Aaron Po
2026-06-20 00:02:27 -04:00
parent 34ba7e8271
commit 5cf4df1fd8
24 changed files with 267 additions and 118 deletions

View File

@@ -0,0 +1,22 @@
using Domain.Entities;
using Features.UserManagement.Commands.UpdateUser;
using Features.UserManagement.Repository;
using Moq;
namespace Features.UserManagement.Tests.Commands;
public class UpdateUserHandlerTests
{
[Fact]
public async Task Handle_DelegatesToRepository()
{
var repoMock = new Mock<IUserAccountRepository>();
var handler = new UpdateUserHandler(repoMock.Object);
var user = new UserAccount { UserAccountId = Guid.NewGuid() };
repoMock.Setup(r => r.UpdateAsync(user)).Returns(Task.CompletedTask);
await handler.Handle(new UpdateUserCommand(user), CancellationToken.None);
repoMock.Verify(r => r.UpdateAsync(user), Times.Once);
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Features.UserManagement.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="6.9.0" />
<PackageReference Include="DbMocker" Version="1.26.0" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Features.UserManagement\Features.UserManagement.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,23 @@
using Domain.Entities;
using FluentAssertions;
using Features.UserManagement.Queries.GetAllUsers;
using Features.UserManagement.Repository;
using Moq;
namespace Features.UserManagement.Tests.Queries;
public class GetAllUsersHandlerTests
{
[Fact]
public async Task Handle_PassesLimitAndOffset_ToRepository()
{
var repoMock = new Mock<IUserAccountRepository>();
var handler = new GetAllUsersHandler(repoMock.Object);
repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());
var result = await handler.Handle(new GetAllUsersQuery(10, 5), CancellationToken.None);
result.Should().BeEmpty();
repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
}
}

View File

@@ -0,0 +1,41 @@
using Domain.Entities;
using Domain.Exceptions;
using FluentAssertions;
using Features.UserManagement.Queries.GetUserById;
using Features.UserManagement.Repository;
using Moq;
namespace Features.UserManagement.Tests.Queries;
public class GetUserByIdHandlerTests
{
private readonly Mock<IUserAccountRepository> _repoMock = new();
private readonly GetUserByIdHandler _handler;
public GetUserByIdHandlerTests()
{
_handler = new GetUserByIdHandler(_repoMock.Object);
}
[Fact]
public async Task Handle_ReturnsUser_WhenFound()
{
var user = new UserAccount { UserAccountId = Guid.NewGuid(), Username = "test" };
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
var result = await _handler.Handle(new GetUserByIdQuery(user.UserAccountId), CancellationToken.None);
result.Should().Be(user);
}
[Fact]
public async Task Handle_Throws_WhenNotFound()
{
var id = Guid.NewGuid();
_repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null);
var act = async () => await _handler.Handle(new GetUserByIdQuery(id), CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
}

View File

@@ -0,0 +1,11 @@
using System.Data.Common;
using Infrastructure.Sql;
namespace Features.UserManagement.Tests.Repository;
internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
{
private readonly DbConnection _conn = conn;
public DbConnection CreateConnection() => _conn;
}

View File

@@ -0,0 +1,179 @@
using Apps72.Dev.Data.DbMocker;
using FluentAssertions;
using Features.UserManagement.Repository;
namespace Features.UserManagement.Tests.Repository;
public class UserAccountRepositoryTests
{
private static UserAccountRepository CreateRepo(MockDbConnection conn) =>
new(new TestConnectionFactory(conn));
[Fact]
public async Task GetByIdAsync_ReturnsRow_Mapped()
{
var conn = new MockDbConnection();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById")
.ReturnsTable(
MockTable
.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
)
.AddRow(
Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
"yerb",
"Aaron",
"Po",
"aaronpo@example.com",
new DateTime(2020, 1, 1),
null,
new DateTime(1990, 1, 1),
null
)
);
var repo = CreateRepo(conn);
var result = await repo.GetByIdAsync(
Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
);
result.Should().NotBeNull();
result!.Username.Should().Be("yerb");
result.Email.Should().Be("aaronpo@example.com");
}
[Fact]
public async Task GetAllAsync_ReturnsMultipleRows()
{
var conn = new MockDbConnection();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetAllUserAccounts")
.ReturnsTable(
MockTable
.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
)
.AddRow(
Guid.NewGuid(),
"a",
"A",
"A",
"a@example.com",
DateTime.UtcNow,
null,
DateTime.UtcNow.Date,
null
)
.AddRow(
Guid.NewGuid(),
"b",
"B",
"B",
"b@example.com",
DateTime.UtcNow,
null,
DateTime.UtcNow.Date,
null
)
);
var repo = CreateRepo(conn);
var results = (await repo.GetAllAsync(null, null)).ToList();
results.Should().HaveCount(2);
results
.Select(r => r.Username)
.Should()
.BeEquivalentTo(new[] { "a", "b" });
}
[Fact]
public async Task GetByUsername_ReturnsRow()
{
var conn = new MockDbConnection();
conn.Mocks.When(cmd =>
cmd.CommandText == "usp_GetUserAccountByUsername"
)
.ReturnsTable(
MockTable
.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
)
.AddRow(
Guid.NewGuid(),
"lookupuser",
"L",
"U",
"lookup@example.com",
DateTime.UtcNow,
null,
DateTime.UtcNow.Date,
null
)
);
var repo = CreateRepo(conn);
var result = await repo.GetByUsernameAsync("lookupuser");
result.Should().NotBeNull();
result!.Email.Should().Be("lookup@example.com");
}
[Fact]
public async Task GetByEmail_ReturnsRow()
{
var conn = new MockDbConnection();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(
MockTable
.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
)
.AddRow(
Guid.NewGuid(),
"byemail",
"B",
"E",
"byemail@example.com",
DateTime.UtcNow,
null,
DateTime.UtcNow.Date,
null
)
);
var repo = CreateRepo(conn);
var result = await repo.GetByEmailAsync("byemail@example.com");
result.Should().NotBeNull();
result!.Username.Should().Be("byemail");
}
}