Add self-service account management commands (delete, update username/email/password/profile)

This commit is contained in:
Aaron Po
2026-08-22 17:24:53 -04:00
parent 700091a590
commit afe4b0cf72
30 changed files with 1143 additions and 42 deletions

View File

@@ -0,0 +1,74 @@
using Domain.Exceptions;
using Features.Auth.Commands.DeleteAccount;
using Features.Auth.Identity;
using Features.Auth.Tests.TestSupport;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Moq;
namespace Features.Auth.Tests.Commands;
public class DeleteAccountHandlerTests
{
private readonly DeleteAccountHandler _handler;
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
public DeleteAccountHandlerTests()
{
_userManagerMock = UserManagerMockFactory.Create();
_handler = new DeleteAccountHandler(_userManagerMock.Object);
}
[Fact]
public async Task Handle_WithExistingUser_DeletesAccount()
{
Guid userId = Guid.NewGuid();
ApplicationUser user = new() { Id = userId };
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
_userManagerMock.Setup(x => x.DeleteAsync(user)).ReturnsAsync(IdentityResult.Success);
await _handler.Handle(new DeleteAccountCommand(userId), CancellationToken.None);
_userManagerMock.Verify(x => x.DeleteAsync(user), Times.Once);
}
[Fact]
public async Task Handle_WithNonExistentUser_ThrowsNotFoundException()
{
Guid userId = Guid.NewGuid();
_userManagerMock
.Setup(x => x.FindByIdAsync(userId.ToString()))
.ReturnsAsync((ApplicationUser?)null);
Func<Task> act = async () =>
await _handler.Handle(new DeleteAccountCommand(userId), CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
[Fact]
public async Task Handle_WithDependentRecords_ThrowsConflictException()
{
Guid userId = Guid.NewGuid();
ApplicationUser user = new() { Id = userId };
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
_userManagerMock
.Setup(x => x.DeleteAsync(user))
.ReturnsAsync(
IdentityResult.Failed(
new IdentityError
{
Code = "AccountHasDependentRecords",
Description = "still referenced",
}
)
);
Func<Task> act = async () =>
await _handler.Handle(new DeleteAccountCommand(userId), CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>();
}
}

View File

@@ -0,0 +1,96 @@
using Domain.Exceptions;
using Features.Auth.Commands.UpdateEmail;
using Features.Auth.Dtos;
using Features.Auth.Identity;
using Features.Auth.Tests.TestSupport;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Moq;
namespace Features.Auth.Tests.Commands;
public class UpdateEmailHandlerTests
{
private readonly UpdateEmailHandler _handler;
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
public UpdateEmailHandlerTests()
{
_userManagerMock = UserManagerMockFactory.Create();
_handler = new UpdateEmailHandler(_userManagerMock.Object);
}
[Fact]
public async Task Handle_WithValidRequest_UpdatesEmailAndResetsConfirmation()
{
Guid userId = Guid.NewGuid();
ApplicationUser user = new()
{
Id = userId,
Email = "old@example.com",
EmailConfirmed = true,
};
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
_userManagerMock
.Setup(x => x.SetEmailAsync(user, "new@example.com"))
.Callback<ApplicationUser, string>(
(u, e) =>
{
u.Email = e;
u.EmailConfirmed = false;
}
)
.ReturnsAsync(IdentityResult.Success);
UpdateEmailPayload result = await _handler.Handle(
new UpdateEmailCommand(userId, "new@example.com"),
CancellationToken.None
);
result.UserAccountId.Should().Be(userId);
result.Email.Should().Be("new@example.com");
result.EmailConfirmed.Should().BeFalse();
}
[Fact]
public async Task Handle_WithNonExistentUser_ThrowsNotFoundException()
{
Guid userId = Guid.NewGuid();
_userManagerMock
.Setup(x => x.FindByIdAsync(userId.ToString()))
.ReturnsAsync((ApplicationUser?)null);
Func<Task<UpdateEmailPayload>> act = async () =>
await _handler.Handle(
new UpdateEmailCommand(userId, "new@example.com"),
CancellationToken.None
);
await act.Should().ThrowAsync<NotFoundException>();
}
[Fact]
public async Task Handle_WithDuplicateEmail_ThrowsConflictException()
{
Guid userId = Guid.NewGuid();
ApplicationUser user = new() { Id = userId, Email = "old@example.com" };
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
_userManagerMock
.Setup(x => x.SetEmailAsync(user, "taken@example.com"))
.ReturnsAsync(
IdentityResult.Failed(
new IdentityError { Code = "DuplicateEmail", Description = "taken" }
)
);
Func<Task<UpdateEmailPayload>> act = async () =>
await _handler.Handle(
new UpdateEmailCommand(userId, "taken@example.com"),
CancellationToken.None
);
await act.Should().ThrowAsync<ConflictException>();
}
}

View File

@@ -0,0 +1,83 @@
using Domain.Exceptions;
using Features.Auth.Commands.UpdatePassword;
using Features.Auth.Dtos;
using Features.Auth.Identity;
using Features.Auth.Tests.TestSupport;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Moq;
namespace Features.Auth.Tests.Commands;
public class UpdatePasswordHandlerTests
{
private readonly UpdatePasswordHandler _handler;
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
public UpdatePasswordHandlerTests()
{
_userManagerMock = UserManagerMockFactory.Create();
_handler = new UpdatePasswordHandler(_userManagerMock.Object);
}
[Fact]
public async Task Handle_WithValidRequest_ChangesPassword()
{
Guid userId = Guid.NewGuid();
ApplicationUser user = new() { Id = userId };
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
_userManagerMock
.Setup(x => x.ChangePasswordAsync(user, "OldPassword1!", "NewPassword1!"))
.ReturnsAsync(IdentityResult.Success);
UpdatePasswordPayload result = await _handler.Handle(
new UpdatePasswordCommand(userId, "OldPassword1!", "NewPassword1!"),
CancellationToken.None
);
result.UserAccountId.Should().Be(userId);
result.ChangedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
}
[Fact]
public async Task Handle_WithNonExistentUser_ThrowsNotFoundException()
{
Guid userId = Guid.NewGuid();
_userManagerMock
.Setup(x => x.FindByIdAsync(userId.ToString()))
.ReturnsAsync((ApplicationUser?)null);
Func<Task<UpdatePasswordPayload>> act = async () =>
await _handler.Handle(
new UpdatePasswordCommand(userId, "OldPassword1!", "NewPassword1!"),
CancellationToken.None
);
await act.Should().ThrowAsync<NotFoundException>();
}
[Fact]
public async Task Handle_WithWrongCurrentPassword_ThrowsUnauthorizedException()
{
Guid userId = Guid.NewGuid();
ApplicationUser user = new() { Id = userId };
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
_userManagerMock
.Setup(x => x.ChangePasswordAsync(user, "WrongPassword1!", "NewPassword1!"))
.ReturnsAsync(
IdentityResult.Failed(
new IdentityError { Code = "PasswordMismatch", Description = "mismatch" }
)
);
Func<Task<UpdatePasswordPayload>> act = async () =>
await _handler.Handle(
new UpdatePasswordCommand(userId, "WrongPassword1!", "NewPassword1!"),
CancellationToken.None
);
await act.Should().ThrowAsync<UnauthorizedException>();
}
}

View File

@@ -0,0 +1,67 @@
using Domain.Exceptions;
using Features.Auth.Commands.UpdateProfile;
using Features.Auth.Dtos;
using Features.Auth.Identity;
using Features.Auth.Tests.TestSupport;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Moq;
namespace Features.Auth.Tests.Commands;
public class UpdateProfileHandlerTests
{
private readonly UpdateProfileHandler _handler;
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
public UpdateProfileHandlerTests()
{
_userManagerMock = UserManagerMockFactory.Create();
_handler = new UpdateProfileHandler(_userManagerMock.Object);
}
[Fact]
public async Task Handle_WithValidRequest_UpdatesProfileFields()
{
Guid userId = Guid.NewGuid();
ApplicationUser user = new()
{
Id = userId,
FirstName = "Old",
LastName = "Name",
DateOfBirth = new DateTime(1990, 1, 1),
};
DateTime newDob = new(1991, 2, 3);
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
_userManagerMock.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
UpdateProfilePayload result = await _handler.Handle(
new UpdateProfileCommand(userId, "New", "Person", newDob),
CancellationToken.None
);
result.UserAccountId.Should().Be(userId);
result.FirstName.Should().Be("New");
result.LastName.Should().Be("Person");
result.DateOfBirth.Should().Be(newDob);
_userManagerMock.Verify(x => x.UpdateAsync(user), Times.Once);
}
[Fact]
public async Task Handle_WithNonExistentUser_ThrowsNotFoundException()
{
Guid userId = Guid.NewGuid();
_userManagerMock
.Setup(x => x.FindByIdAsync(userId.ToString()))
.ReturnsAsync((ApplicationUser?)null);
Func<Task<UpdateProfilePayload>> act = async () =>
await _handler.Handle(
new UpdateProfileCommand(userId, "New", "Person", DateTime.Today),
CancellationToken.None
);
await act.Should().ThrowAsync<NotFoundException>();
}
}

View File

@@ -0,0 +1,84 @@
using Domain.Exceptions;
using Features.Auth.Commands.UpdateUsername;
using Features.Auth.Dtos;
using Features.Auth.Identity;
using Features.Auth.Tests.TestSupport;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Moq;
namespace Features.Auth.Tests.Commands;
public class UpdateUsernameHandlerTests
{
private readonly UpdateUsernameHandler _handler;
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
public UpdateUsernameHandlerTests()
{
_userManagerMock = UserManagerMockFactory.Create();
_handler = new UpdateUsernameHandler(_userManagerMock.Object);
}
[Fact]
public async Task Handle_WithValidRequest_UpdatesUsername()
{
Guid userId = Guid.NewGuid();
ApplicationUser user = new() { Id = userId, UserName = "old-name" };
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
_userManagerMock
.Setup(x => x.SetUserNameAsync(user, "new-name"))
.Callback<ApplicationUser, string>((u, n) => u.UserName = n)
.ReturnsAsync(IdentityResult.Success);
UpdateUsernamePayload result = await _handler.Handle(
new UpdateUsernameCommand(userId, "new-name"),
CancellationToken.None
);
result.UserAccountId.Should().Be(userId);
result.Username.Should().Be("new-name");
}
[Fact]
public async Task Handle_WithNonExistentUser_ThrowsNotFoundException()
{
Guid userId = Guid.NewGuid();
_userManagerMock
.Setup(x => x.FindByIdAsync(userId.ToString()))
.ReturnsAsync((ApplicationUser?)null);
Func<Task<UpdateUsernamePayload>> act = async () =>
await _handler.Handle(
new UpdateUsernameCommand(userId, "new-name"),
CancellationToken.None
);
await act.Should().ThrowAsync<NotFoundException>();
}
[Fact]
public async Task Handle_WithDuplicateUsername_ThrowsConflictException()
{
Guid userId = Guid.NewGuid();
ApplicationUser user = new() { Id = userId, UserName = "old-name" };
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
_userManagerMock
.Setup(x => x.SetUserNameAsync(user, "taken-name"))
.ReturnsAsync(
IdentityResult.Failed(
new IdentityError { Code = "DuplicateUserName", Description = "taken" }
)
);
Func<Task<UpdateUsernamePayload>> act = async () =>
await _handler.Handle(
new UpdateUsernameCommand(userId, "taken-name"),
CancellationToken.None
);
await act.Should().ThrowAsync<ConflictException>();
}
}