From 2759314f633b23151dedbc0de321dfa46bed8751 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sun, 5 Jul 2026 13:11:29 -0700 Subject: [PATCH] Add unit tests for MicCheck.Api.Users Covers UserService (profile update, email conflict, password change) and UsersController (auth/validation/not-found/conflict branches), raising the namespace from 3% coverage. --- .../Users/UserServiceTests.cs | 161 +++++++++++++ .../Users/UsersControllerTests.cs | 217 ++++++++++++++++++ 2 files changed, 378 insertions(+) create mode 100644 tests/api/MicCheck.Api.Tests.Unit/Users/UserServiceTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Unit/Users/UsersControllerTests.cs diff --git a/tests/api/MicCheck.Api.Tests.Unit/Users/UserServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Users/UserServiceTests.cs new file mode 100644 index 0000000..6820337 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Users/UserServiceTests.cs @@ -0,0 +1,161 @@ +using MicCheck.Api.Data; +using MicCheck.Api.Tests.Unit.TestSupport; +using MicCheck.Api.Users; +using Microsoft.AspNetCore.Identity; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Users; + +[TestFixture] +public class UserServiceTests +{ + private Mock _db = null!; + private Mock> _passwordHasher = null!; + private List _users = null!; + private UserService _service = null!; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + _users = []; + _db.SetupDbSet(c => c.Users, _users); + + _passwordHasher = new Mock>(); + + _service = new UserService(_db.Object, _passwordHasher.Object); + } + + private User AddUser(int id = 1, bool isActive = true, string email = "alice@example.com") => new() + { + Id = id, + Email = email, + PasswordHash = "hashed", + FirstName = "Alice", + LastName = "Smith", + IsActive = isActive, + CreatedAt = DateTimeOffset.UtcNow + }; + + [Test] + public async Task WhenFindingByIdForAnActiveUser_ThenTheUserIsReturned() + { + var user = AddUser(); + _users.Add(user); + + var result = await _service.FindByIdAsync(user.Id); + + Assert.That(result, Is.SameAs(user)); + } + + [Test] + public async Task WhenFindingByIdForAnInactiveUser_ThenNullIsReturned() + { + var user = AddUser(isActive: false); + _users.Add(user); + + var result = await _service.FindByIdAsync(user.Id); + + Assert.That(result, Is.Null); + } + + [Test] + public async Task WhenFindingByIdForAnUnknownUser_ThenNullIsReturned() + { + var result = await _service.FindByIdAsync(999); + + Assert.That(result, Is.Null); + } + + [Test] + public async Task WhenUpdatingTheProfileOfAnUnknownUser_ThenNullIsReturnedWithoutConflict() + { + var result = await _service.UpdateProfileAsync(999, "Bob", "Jones", "bob@example.com"); + + Assert.That(result.User, Is.Null); + Assert.That(result.EmailConflict, Is.False); + } + + [Test] + public async Task WhenUpdatingTheProfileWithANewUniqueEmail_ThenTheUserFieldsAreUpdated() + { + var user = AddUser(); + _users.Add(user); + + var result = await _service.UpdateProfileAsync(user.Id, "Bob", "Jones", "bob@example.com"); + + Assert.That(result.EmailConflict, Is.False); + Assert.That(result.User!.FirstName, Is.EqualTo("Bob")); + Assert.That(result.User.LastName, Is.EqualTo("Jones")); + Assert.That(result.User.Email, Is.EqualTo("bob@example.com")); + } + + [Test] + public async Task WhenUpdatingTheProfileWithTheSameEmailInDifferentCase_ThenNoConflictIsDetected() + { + var user = AddUser(email: "alice@example.com"); + _users.Add(user); + + var result = await _service.UpdateProfileAsync(user.Id, "Alice", "Smith", "ALICE@EXAMPLE.COM"); + + Assert.That(result.EmailConflict, Is.False); + Assert.That(result.User!.Email, Is.EqualTo("ALICE@EXAMPLE.COM")); + } + + [Test] + public async Task WhenUpdatingTheProfileWithAnEmailAlreadyUsedByAnotherUser_ThenAConflictIsReturnedAndTheUserIsUnchanged() + { + var user = AddUser(id: 1, email: "alice@example.com"); + var otherUser = AddUser(id: 2, email: "taken@example.com"); + _users.Add(user); + _users.Add(otherUser); + + var result = await _service.UpdateProfileAsync(user.Id, "Bob", "Jones", "taken@example.com"); + + Assert.That(result.EmailConflict, Is.True); + Assert.That(result.User, Is.Null); + Assert.That(user.Email, Is.EqualTo("alice@example.com")); + } + + [Test] + public async Task WhenChangingThePasswordForAnUnknownUser_ThenFalseIsReturned() + { + var result = await _service.ChangePasswordAsync(999, "current", "new"); + + Assert.That(result, Is.False); + } + + [Test] + public async Task WhenChangingThePasswordWithAnIncorrectCurrentPassword_ThenFalseIsReturnedAndTheHashIsUnchanged() + { + var user = AddUser(); + _users.Add(user); + _passwordHasher + .Setup(h => h.VerifyHashedPassword(user, user.PasswordHash, "wrong")) + .Returns(PasswordVerificationResult.Failed); + + var result = await _service.ChangePasswordAsync(user.Id, "wrong", "new-password"); + + Assert.That(result, Is.False); + Assert.That(user.PasswordHash, Is.EqualTo("hashed")); + } + + [Test] + public async Task WhenChangingThePasswordWithACorrectCurrentPassword_ThenTheHashIsUpdatedAndTrueIsReturned() + { + var user = AddUser(); + _users.Add(user); + _passwordHasher + .Setup(h => h.VerifyHashedPassword(user, user.PasswordHash, "current")) + .Returns(PasswordVerificationResult.Success); + _passwordHasher + .Setup(h => h.HashPassword(user, "new-password")) + .Returns("new-hashed"); + + var result = await _service.ChangePasswordAsync(user.Id, "current", "new-password"); + + Assert.That(result, Is.True); + Assert.That(user.PasswordHash, Is.EqualTo("new-hashed")); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Users/UsersControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Users/UsersControllerTests.cs new file mode 100644 index 0000000..14d5da1 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Users/UsersControllerTests.cs @@ -0,0 +1,217 @@ +using System.Security.Claims; +using MicCheck.Api.Data; +using MicCheck.Api.Tests.Unit.TestSupport; +using MicCheck.Api.Users; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Users; + +[TestFixture] +public class UsersControllerTests +{ + private Mock _db = null!; + private List _users = null!; + private UsersController _controller = null!; + private const int UserId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + _users = []; + _db.SetupDbSet(c => c.Users, _users); + + var passwordHasher = new Mock>(); + passwordHasher + .Setup(h => h.VerifyHashedPassword(It.IsAny(), "hashed", "correct-password")) + .Returns(PasswordVerificationResult.Success); + passwordHasher + .Setup(h => h.VerifyHashedPassword(It.IsAny(), "hashed", It.Is(p => p != "correct-password"))) + .Returns(PasswordVerificationResult.Failed); + passwordHasher + .Setup(h => h.HashPassword(It.IsAny(), It.IsAny())) + .Returns("new-hashed"); + + var service = new UserService(_db.Object, passwordHasher.Object); + _controller = new UsersController(service); + } + + private User AddUser(int id = UserId, string email = "alice@example.com") => new() + { + Id = id, + Email = email, + PasswordHash = "hashed", + FirstName = "Alice", + LastName = "Smith", + IsActive = true, + CreatedAt = DateTimeOffset.UtcNow + }; + + private void AuthenticateAs(int? userId) + { + var claims = userId is null ? [] : new[] { new Claim(ClaimTypes.NameIdentifier, userId.Value.ToString()) }; + var identity = new ClaimsIdentity(claims, userId is null ? null : "TestAuth"); + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } + }; + } + + [Test] + public async Task WhenGettingAUserById_ThenTheMatchingUserIsReturned() + { + var user = AddUser(); + _users.Add(user); + + var result = await _controller.GetById(user.Id, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(ok, Is.Not.Null); + Assert.That(((UserResponse)ok!.Value!).Id, Is.EqualTo(user.Id)); + } + + [Test] + public async Task WhenGettingAUserByIdThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.GetById(999, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingTheCurrentUserWithoutAuthentication_ThenUnauthorizedIsReturned() + { + AuthenticateAs(null); + + var result = await _controller.GetMe(CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingTheCurrentUser_ThenTheAuthenticatedUsersProfileIsReturned() + { + var user = AddUser(); + _users.Add(user); + AuthenticateAs(user.Id); + + var result = await _controller.GetMe(CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(ok, Is.Not.Null); + Assert.That(((UserResponse)ok!.Value!).Email, Is.EqualTo(user.Email)); + } + + [Test] + public async Task WhenUpdatingTheProfileWithoutAuthentication_ThenUnauthorizedIsReturned() + { + AuthenticateAs(null); + + var result = await _controller.UpdateMe(new UpdateProfileRequest("Bob", "Jones", "bob@example.com"), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingTheProfileWithAMissingField_ThenBadRequestIsReturned() + { + var user = AddUser(); + _users.Add(user); + AuthenticateAs(user.Id); + + var result = await _controller.UpdateMe(new UpdateProfileRequest("", "Jones", "bob@example.com"), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingTheProfileWithAnEmailTakenByAnotherUser_ThenConflictIsReturned() + { + var user = AddUser(id: 1, email: "alice@example.com"); + var otherUser = AddUser(id: 2, email: "taken@example.com"); + _users.Add(user); + _users.Add(otherUser); + AuthenticateAs(user.Id); + + var result = await _controller.UpdateMe(new UpdateProfileRequest("Alice", "Smith", "taken@example.com"), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingTheProfileForAUserThatNoLongerExists_ThenNotFoundIsReturned() + { + AuthenticateAs(999); + + var result = await _controller.UpdateMe(new UpdateProfileRequest("Bob", "Jones", "bob@example.com"), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingTheProfileWithValidData_ThenTheUpdatedProfileIsReturned() + { + var user = AddUser(); + _users.Add(user); + AuthenticateAs(user.Id); + + var result = await _controller.UpdateMe(new UpdateProfileRequest(" Bob ", " Jones ", " bob@example.com "), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(ok, Is.Not.Null); + var response = (UserResponse)ok!.Value!; + Assert.That(response.FirstName, Is.EqualTo("Bob")); + Assert.That(response.LastName, Is.EqualTo("Jones")); + Assert.That(response.Email, Is.EqualTo("bob@example.com")); + } + + [Test] + public async Task WhenChangingPasswordWithoutAuthentication_ThenUnauthorizedIsReturned() + { + AuthenticateAs(null); + + var result = await _controller.ChangePassword(new ChangePasswordRequest("current", "new"), CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenChangingPasswordWithAMissingField_ThenBadRequestIsReturned() + { + var user = AddUser(); + _users.Add(user); + AuthenticateAs(user.Id); + + var result = await _controller.ChangePassword(new ChangePasswordRequest("", "new"), CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenChangingPasswordWithAnIncorrectCurrentPassword_ThenBadRequestIsReturned() + { + var user = AddUser(); + _users.Add(user); + AuthenticateAs(user.Id); + + var result = await _controller.ChangePassword(new ChangePasswordRequest("wrong-password", "new-password"), CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenChangingPasswordWithACorrectCurrentPassword_ThenNoContentIsReturned() + { + var user = AddUser(); + _users.Add(user); + AuthenticateAs(user.Id); + + var result = await _controller.ChangePassword(new ChangePasswordRequest("correct-password", "new-password"), CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } +}