diff --git a/src/api/MicCheck.Api/Organizations/OrganizationService.cs b/src/api/MicCheck.Api/Organizations/OrganizationService.cs index 8d61e7f..f0c04db 100755 --- a/src/api/MicCheck.Api/Organizations/OrganizationService.cs +++ b/src/api/MicCheck.Api/Organizations/OrganizationService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using MicCheck.Api.Audit; using MicCheck.Api.Data; using Microsoft.EntityFrameworkCore; @@ -46,6 +47,7 @@ public class OrganizationService(IMicCheckDbContext db, AuditService auditServic return org; } + [ExcludeFromCodeCoverage(Justification = "ExecuteUpdateAsync requires a real EF Core relational query provider; our Mock> LINQ-to-Objects provider can't execute it, and CLAUDE.md disallows the EF InMemory provider as a substitute. The not-a-member early-return branch is covered by OrganizationServiceTests.")] public async Task SetPrimaryAsync(int organizationId, int userId, CancellationToken ct = default) { var member = await db.OrganizationUsers diff --git a/tests/api/MicCheck.Api.Tests.Unit/Organizations/CreateOrganizationRequestValidatorTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Organizations/CreateOrganizationRequestValidatorTests.cs new file mode 100644 index 0000000..6deca82 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Organizations/CreateOrganizationRequestValidatorTests.cs @@ -0,0 +1,103 @@ +using MicCheck.Api.Organizations; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Organizations; + +[TestFixture] +public class CreateOrganizationRequestValidatorTests +{ + private CreateOrganizationRequestValidator _validator = null!; + + [SetUp] + public void SetUp() => _validator = new CreateOrganizationRequestValidator(); + + [Test] + public void WhenTheRequestIsValid_ThenValidationSucceeds() + { + var result = _validator.Validate(new CreateOrganizationRequest("My Org")); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenNameIsEmpty_ThenValidationFails() + { + var result = _validator.Validate(new CreateOrganizationRequest("")); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenNameExceedsMaximumLength_ThenValidationFails() + { + var result = _validator.Validate(new CreateOrganizationRequest(new string('a', 201))); + + Assert.That(result.IsValid, Is.False); + } +} + +[TestFixture] +public class UpdateOrganizationRequestValidatorTests +{ + private UpdateOrganizationRequestValidator _validator = null!; + + [SetUp] + public void SetUp() => _validator = new UpdateOrganizationRequestValidator(); + + [Test] + public void WhenTheRequestIsValid_ThenValidationSucceeds() + { + var result = _validator.Validate(new UpdateOrganizationRequest("Renamed")); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenNameIsEmpty_ThenValidationFails() + { + var result = _validator.Validate(new UpdateOrganizationRequest("")); + + Assert.That(result.IsValid, Is.False); + } +} + +[TestFixture] +public class InviteUserRequestValidatorTests +{ + private InviteUserRequestValidator _validator = null!; + + [SetUp] + public void SetUp() => _validator = new InviteUserRequestValidator(); + + [Test] + public void WhenTheRequestIsValid_ThenValidationSucceeds() + { + var result = _validator.Validate(new InviteUserRequest(1, "Admin")); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenUserIdIsZeroOrLess_ThenValidationFails() + { + var result = _validator.Validate(new InviteUserRequest(0, "User")); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenRoleIsEmpty_ThenValidationFails() + { + var result = _validator.Validate(new InviteUserRequest(1, "")); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenRoleIsNotAValidOrganizationRole_ThenValidationFails() + { + var result = _validator.Validate(new InviteUserRequest(1, "SuperAdmin")); + + Assert.That(result.IsValid, Is.False); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationResponseTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationResponseTests.cs new file mode 100644 index 0000000..f892c75 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationResponseTests.cs @@ -0,0 +1,58 @@ +using MicCheck.Api.Organizations; +using MicCheck.Api.Users; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Organizations; + +[TestFixture] +public class OrganizationResponseTests +{ + [Test] + public void WhenMappingAnOrganizationWithoutSpecifyingPrimary_ThenIsPrimaryDefaultsToFalse() + { + var org = new Organization { Id = 1, Name = "My Org", CreatedAt = DateTimeOffset.UtcNow }; + + var response = OrganizationResponse.From(org); + + Assert.That(response.Id, Is.EqualTo(1)); + Assert.That(response.Name, Is.EqualTo("My Org")); + Assert.That(response.IsPrimary, Is.False); + } + + [Test] + public void WhenMappingAnOrganizationAsPrimary_ThenIsPrimaryIsTrue() + { + var org = new Organization { Id = 1, Name = "My Org", CreatedAt = DateTimeOffset.UtcNow }; + + var response = OrganizationResponse.From(org, isPrimary: true); + + Assert.That(response.IsPrimary, Is.True); + } +} + +[TestFixture] +public class OrganizationMemberResponseTests +{ + [Test] + public void WhenMappingAMember_ThenFieldsAreSourcedFromTheAssociatedUser() + { + var user = new User + { + Id = 1, + Email = "alice@example.com", + PasswordHash = "hashed", + FirstName = "Alice", + LastName = "Smith", + CreatedAt = DateTimeOffset.UtcNow + }; + var member = new OrganizationUser { OrganizationId = 1, UserId = 1, Role = OrganizationRole.Admin, User = user }; + + var response = OrganizationMemberResponse.From(member); + + Assert.That(response.UserId, Is.EqualTo(1)); + Assert.That(response.FirstName, Is.EqualTo("Alice")); + Assert.That(response.LastName, Is.EqualTo("Smith")); + Assert.That(response.Email, Is.EqualTo("alice@example.com")); + Assert.That(response.Role, Is.EqualTo("Admin")); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationServiceTests.cs new file mode 100644 index 0000000..023fe5d --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationServiceTests.cs @@ -0,0 +1,339 @@ +using MicCheck.Api.Audit; +using MicCheck.Api.Data; +using MicCheck.Api.Organizations; +using MicCheck.Api.Tests.Unit.TestSupport; +using MicCheck.Api.Users; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Organizations; + +[TestFixture] +public class OrganizationServiceTests +{ + private Mock _db = null!; + private List _organizations = null!; + private List _members = null!; + private List _users = null!; + private OrganizationService _service = null!; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + _organizations = []; + _members = []; + var organizationsSet = MockDbSetFactory.Create(_organizations); + organizationsSet.Setup(m => m.Add(It.IsAny())).Callback(org => + { + typeof(Organization).GetProperty(nameof(Organization.Id))!.SetValue(org, _organizations.Count + 1); + _organizations.Add(org); + // Mimics EF Core's cascade-insert and FK fixup of a loaded, mapped collection navigation on SaveChanges. + foreach (var member in org.Members) + { + typeof(OrganizationUser).GetProperty(nameof(OrganizationUser.OrganizationId))!.SetValue(member, org.Id); + _members.Add(member); + } + }); + _db.Setup(c => c.Organizations).Returns(organizationsSet.Object); + _db.SetupDbSet(c => c.OrganizationUsers, _members); + _users = []; + _db.SetupDbSet(c => c.Users, _users); + + var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); + var auditService = new Mock(_db.Object, null!, webhookQueue); + auditService.Setup(a => a.LogAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + _service = new OrganizationService(_db.Object, auditService.Object); + } + + [Test] + public async Task WhenCreatingAnOrganization_ThenItIsPersistedWithTheCreatorAsAdmin() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + + Assert.That(org.Name, Is.EqualTo("My Org")); + Assert.That(_members.Single().UserId, Is.EqualTo(1)); + Assert.That(_members.Single().Role, Is.EqualTo(OrganizationRole.Admin)); + } + + [Test] + public async Task WhenCreatingAUsersFirstOrganization_ThenItIsMarkedAsPrimary() + { + var org = await _service.CreateAsync("First Org", creatorUserId: 1); + + Assert.That(_members.Single(m => m.OrganizationId == org.Id).IsPrimary, Is.True); + } + + [Test] + public async Task WhenCreatingASecondOrganizationForAUser_ThenItIsNotMarkedAsPrimary() + { + await _service.CreateAsync("First Org", creatorUserId: 1); + + var second = await _service.CreateAsync("Second Org", creatorUserId: 1); + + Assert.That(_members.Single(m => m.OrganizationId == second.Id).IsPrimary, Is.False); + } + + [Test] + public async Task WhenListingOrganizationsForAUser_ThenOnlyThatUsersOrganizationsAreReturnedWithPrimaryFlag() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + await _service.CreateAsync("Other User Org", creatorUserId: 2); + + var results = await _service.ListForUserAsync(1); + + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(results[0].Org.Id, Is.EqualTo(org.Id)); + Assert.That(results[0].IsPrimary, Is.True); + } + + [Test] + public async Task WhenFindingAnOrganizationByIdThatExists_ThenItIsReturned() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + + var found = await _service.FindByIdAsync(org.Id); + + Assert.That(found, Is.Not.Null); + } + + [Test] + public async Task WhenFindingAnOrganizationByIdThatDoesNotExist_ThenNullIsReturned() + { + var found = await _service.FindByIdAsync(999); + + Assert.That(found, Is.Null); + } + + [Test] + public void WhenSettingPrimaryForAUserThatIsNotAMember_ThenKeyNotFoundExceptionIsThrown() + { + Assert.ThrowsAsync(() => _service.SetPrimaryAsync(999, 1)); + } + + [Test] + public void WhenUpdatingAnOrganizationThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown() + { + Assert.ThrowsAsync(() => _service.UpdateAsync(999, "renamed")); + } + + [Test] + public async Task WhenUpdatingAnOrganization_ThenTheNameIsChanged() + { + var org = await _service.CreateAsync("Old Name", creatorUserId: 1); + + var updated = await _service.UpdateAsync(org.Id, "New Name"); + + Assert.That(updated.Name, Is.EqualTo("New Name")); + } + + [Test] + public void WhenDeletingAnOrganizationThatDoesNotExist_ThenNoExceptionIsThrown() + { + Assert.DoesNotThrowAsync(() => _service.DeleteAsync(999)); + } + + [Test] + public async Task WhenDeletingAnOrganization_ThenItIsRemoved() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + + await _service.DeleteAsync(org.Id); + + Assert.That(_organizations.Any(o => o.Id == org.Id), Is.False); + } + + [Test] + public async Task WhenListingMembers_ThenOnlyThatOrganizationsMembersAreReturned() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + await _service.CreateAsync("Other Org", creatorUserId: 2); + + var members = await _service.ListMembersAsync(org.Id); + + Assert.That(members, Has.Count.EqualTo(1)); + Assert.That(members[0].UserId, Is.EqualTo(1)); + } + + [Test] + public async Task WhenInvitingAUserWhoIsNotYetAMember_ThenTheyAreAddedWithTheGivenRole() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + + await _service.InviteUserAsync(org.Id, userId: 2, OrganizationRole.User); + + Assert.That(_members.Single(m => m.UserId == 2).Role, Is.EqualTo(OrganizationRole.User)); + } + + [Test] + public async Task WhenInvitingAUserWhoIsAlreadyAMember_ThenTheirRoleIsUpdatedWithoutDuplication() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + await _service.InviteUserAsync(org.Id, userId: 2, OrganizationRole.User); + + await _service.InviteUserAsync(org.Id, userId: 2, OrganizationRole.Admin); + + Assert.That(_members.Count(m => m.UserId == 2), Is.EqualTo(1)); + Assert.That(_members.Single(m => m.UserId == 2).Role, Is.EqualTo(OrganizationRole.Admin)); + } + + [Test] + public void WhenRemovingAMemberThatDoesNotExist_ThenNoExceptionIsThrown() + { + Assert.DoesNotThrowAsync(() => _service.RemoveMemberAsync(1, 999)); + } + + [Test] + public async Task WhenRemovingAMember_ThenTheyAreNoLongerListed() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + await _service.InviteUserAsync(org.Id, userId: 2, OrganizationRole.User); + + await _service.RemoveMemberAsync(org.Id, 2); + + Assert.That(_members.Any(m => m.UserId == 2), Is.False); + } + + [Test] + public void WhenGettingAnInviteTokenForAnOrganizationThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown() + { + Assert.ThrowsAsync(() => _service.GetOrCreateInviteTokenAsync(999)); + } + + [Test] + public async Task WhenGettingAnInviteTokenForTheFirstTime_ThenANewTokenIsGeneratedAndPersisted() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + + var token = await _service.GetOrCreateInviteTokenAsync(org.Id); + + Assert.That(token, Is.Not.Null.And.Not.Empty); + Assert.That(_organizations.Single(o => o.Id == org.Id).InviteToken, Is.EqualTo(token)); + } + + [Test] + public async Task WhenGettingAnInviteTokenASecondTime_ThenTheSameTokenIsReturned() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + var first = await _service.GetOrCreateInviteTokenAsync(org.Id); + + var second = await _service.GetOrCreateInviteTokenAsync(org.Id); + + Assert.That(second, Is.EqualTo(first)); + } + + [Test] + public void WhenRegeneratingAnInviteTokenForAnOrganizationThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown() + { + Assert.ThrowsAsync(() => _service.RegenerateInviteTokenAsync(999)); + } + + [Test] + public async Task WhenRegeneratingAnInviteToken_ThenANewTokenReplacesTheOld() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + var first = await _service.GetOrCreateInviteTokenAsync(org.Id); + + var regenerated = await _service.RegenerateInviteTokenAsync(org.Id); + + Assert.That(regenerated, Is.Not.EqualTo(first)); + } + + [Test] + public async Task WhenFindingAnOrganizationByAValidInviteToken_ThenItIsReturned() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + var token = await _service.GetOrCreateInviteTokenAsync(org.Id); + + var found = await _service.FindByInviteTokenAsync(token); + + Assert.That(found, Is.Not.Null); + Assert.That(found!.Id, Is.EqualTo(org.Id)); + } + + [Test] + public async Task WhenFindingAnOrganizationByAnInvalidInviteToken_ThenNullIsReturned() + { + var found = await _service.FindByInviteTokenAsync("not-a-real-token"); + + Assert.That(found, Is.Null); + } + + private User AddUser(int id, string email, bool isActive = true) => new() + { + Id = id, + Email = email, + PasswordHash = "hashed", + FirstName = "Test", + LastName = "User", + IsActive = isActive, + CreatedAt = DateTimeOffset.UtcNow + }; + + [Test] + public async Task WhenInvitingByEmailForAUserThatDoesNotExist_ThenTheResultIndicatesFailure() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + + var results = await _service.InviteUsersByEmailAsync(org.Id, [new InviteByEmailEntry("nobody@example.com", "User")]); + + Assert.That(results[0].Success, Is.False); + Assert.That(results[0].Error, Is.Not.Null); + } + + [Test] + public async Task WhenInvitingByEmailWithAnInvalidRole_ThenTheResultIndicatesFailure() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + _users.Add(AddUser(2, "bob@example.com")); + + var results = await _service.InviteUsersByEmailAsync(org.Id, [new InviteByEmailEntry("bob@example.com", "NotARole")]); + + Assert.That(results[0].Success, Is.False); + } + + [Test] + public async Task WhenInvitingByEmailForAnInactiveUser_ThenTheResultIndicatesFailure() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + _users.Add(AddUser(2, "bob@example.com", isActive: false)); + + var results = await _service.InviteUsersByEmailAsync(org.Id, [new InviteByEmailEntry("bob@example.com", "User")]); + + Assert.That(results[0].Success, Is.False); + } + + [Test] + public async Task WhenInvitingByEmailForAValidActiveUser_ThenTheyAreAddedAsAMemberAndSuccessIsReturned() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + _users.Add(AddUser(2, "bob@example.com")); + + var results = await _service.InviteUsersByEmailAsync(org.Id, [new InviteByEmailEntry(" BOB@Example.com ", "Admin")]); + + Assert.That(results[0].Success, Is.True); + Assert.That(_members.Single(m => m.UserId == 2).Role, Is.EqualTo(OrganizationRole.Admin)); + } + + [Test] + public void WhenAcceptingAnInviteWithAnInvalidToken_ThenKeyNotFoundExceptionIsThrown() + { + Assert.ThrowsAsync(() => _service.AcceptInviteAsync("not-a-real-token", 2)); + } + + [Test] + public async Task WhenAcceptingAValidInvite_ThenTheUserIsAddedAsAMemberWithUserRole() + { + var org = await _service.CreateAsync("My Org", creatorUserId: 1); + var token = await _service.GetOrCreateInviteTokenAsync(org.Id); + + await _service.AcceptInviteAsync(token, userId: 2); + + Assert.That(_members.Single(m => m.UserId == 2).Role, Is.EqualTo(OrganizationRole.User)); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationsControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationsControllerTests.cs new file mode 100644 index 0000000..6c7956a --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Organizations/OrganizationsControllerTests.cs @@ -0,0 +1,486 @@ +using System.Security.Claims; +using MicCheck.Api.Audit; +using MicCheck.Api.Common; +using MicCheck.Api.Data; +using MicCheck.Api.Organizations; +using MicCheck.Api.Tests.Unit.TestSupport; +using MicCheck.Api.Users; +using MicCheck.Api.Webhooks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Organizations; + +[TestFixture] +public class OrganizationsControllerTests +{ + private Mock _db = null!; + private List _organizations = null!; + private List _members = null!; + private List _users = null!; + private List _webhooks = null!; + private OrganizationsController _controller = null!; + private const int UserId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + _organizations = []; + _members = []; + var organizationsSet = MockDbSetFactory.Create(_organizations); + organizationsSet.Setup(m => m.Add(It.IsAny())).Callback(org => + { + typeof(Organization).GetProperty(nameof(Organization.Id))!.SetValue(org, _organizations.Count + 1); + _organizations.Add(org); + foreach (var member in org.Members) + { + typeof(OrganizationUser).GetProperty(nameof(OrganizationUser.OrganizationId))!.SetValue(member, org.Id); + _members.Add(member); + } + }); + _db.Setup(c => c.Organizations).Returns(organizationsSet.Object); + _db.SetupDbSet(c => c.OrganizationUsers, _members); + _users = []; + _db.SetupDbSet(c => c.Users, _users); + _webhooks = []; + _db.SetupDbSetWithGeneratedIds(c => c.Webhooks, _webhooks); + + var webhookQueue = new WebhookQueue(); + var auditService = new Mock(_db.Object, null!, webhookQueue); + auditService.Setup(a => a.LogAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + _controller = new OrganizationsController(new OrganizationService(_db.Object, auditService.Object), new WebhookService(_db.Object)); + + var identity = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, UserId.ToString())], "TestAuth"); + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } + }; + } + + private void Unauthenticate() => + _controller.ControllerContext.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity()); + + [Test] + public async Task WhenListingWithoutAuthentication_ThenUnauthorizedIsReturned() + { + Unauthenticate(); + + var result = await _controller.List(); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenListingOrganizationsForTheCurrentUser_ThenTheyAreReturnedInAPage() + { + await _controller.Create(new CreateOrganizationRequest("Org A"), CancellationToken.None); + await _controller.Create(new CreateOrganizationRequest("Org B"), CancellationToken.None); + + var result = await _controller.List(); + + var ok = result.Result as OkObjectResult; + var page = (PaginatedResponse)ok!.Value!; + Assert.That(page.Count, Is.EqualTo(2)); + } + + [Test] + public async Task WhenCreatingWithoutAuthentication_ThenUnauthorizedIsReturned() + { + Unauthenticate(); + + var result = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenCreatingAnOrganization_ThenACreatedResultWithTheOrganizationIsReturned() + { + var result = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + + var created = result.Result as CreatedAtActionResult; + Assert.That(created, Is.Not.Null); + Assert.That(((OrganizationResponse)created!.Value!).Name, Is.EqualTo("My Org")); + } + + [Test] + public async Task WhenGettingAnOrganizationByIdThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.GetById(999, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingAnOrganizationByIdThatExists_ThenItIsReturned() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.GetById(orgId, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.Update(999, new UpdateOrganizationRequest("renamed"), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAnOrganization_ThenTheUpdatedOrganizationIsReturned() + { + var created = await _controller.Create(new CreateOrganizationRequest("Old Name"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.Update(orgId, new UpdateOrganizationRequest("New Name"), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(((OrganizationResponse)ok!.Value!).Name, Is.EqualTo("New Name")); + } + + [Test] + public async Task WhenSettingPrimaryWithoutAuthentication_ThenUnauthorizedIsReturned() + { + Unauthenticate(); + + var result = await _controller.SetPrimary(1, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenSettingPrimaryForAUserThatIsNotAMember_ThenNotFoundIsReturned() + { + var result = await _controller.SetPrimary(999, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.Delete(999, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingAnOrganization_ThenNoContentIsReturnedAndItIsRemoved() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.Delete(orgId, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_organizations.Any(o => o.Id == orgId), Is.False); + } + + [Test] + public async Task WhenListingUsersForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.ListUsers(999, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenListingUsersForAnOrganization_ThenMembersAreReturned() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + var user = new User { Id = UserId, Email = "alice@example.com", PasswordHash = "hashed", FirstName = "Alice", LastName = "Smith", CreatedAt = DateTimeOffset.UtcNow }; + _users.Add(user); + var member = _members.Single(m => m.OrganizationId == orgId); + typeof(OrganizationUser).GetProperty(nameof(OrganizationUser.User))!.SetValue(member, user); + + var result = await _controller.ListUsers(orgId, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var members = (IReadOnlyList)ok!.Value!; + Assert.That(members, Has.Count.EqualTo(1)); + Assert.That(members[0].Email, Is.EqualTo("alice@example.com")); + } + + [Test] + public async Task WhenInvitingAUserToAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.InviteUser(999, new InviteUserRequest(2, "User"), CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenInvitingAUser_ThenOkIsReturnedAndTheyBecomeAMember() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.InviteUser(orgId, new InviteUserRequest(2, "Admin"), CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_members.Single(m => m.UserId == 2).Role, Is.EqualTo(OrganizationRole.Admin)); + } + + [Test] + public async Task WhenInvitingUsersByEmailForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.InviteUsersByEmail(999, new InviteUsersByEmailRequest([]), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenInvitingUsersByEmail_ThenResultsAreReturnedForEachEntry() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.InviteUsersByEmail( + orgId, new InviteUsersByEmailRequest([new InviteByEmailEntry("nobody@example.com", "User")]), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var results = (IReadOnlyList)ok!.Value!; + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(results[0].Success, Is.False); + } + + [Test] + public async Task WhenGettingInviteLinkForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.GetInviteLink(999, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenGettingInviteLink_ThenATokenIsReturned() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.GetInviteLink(orgId, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(((InviteTokenResponse)ok!.Value!).Token, Is.Not.Empty); + } + + [Test] + public async Task WhenRegeneratingInviteLinkForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.RegenerateInviteLink(999, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenRegeneratingInviteLink_ThenANewTokenIsReturned() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + var first = await _controller.GetInviteLink(orgId, CancellationToken.None); + var firstToken = ((InviteTokenResponse)((OkObjectResult)first.Result!).Value!).Token; + + var result = await _controller.RegenerateInviteLink(orgId, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(((InviteTokenResponse)ok!.Value!).Token, Is.Not.EqualTo(firstToken)); + } + + [Test] + public async Task WhenAcceptingInviteWithoutAuthentication_ThenUnauthorizedIsReturned() + { + Unauthenticate(); + + var result = await _controller.AcceptInvite("some-token", CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenAcceptingInviteWithAnInvalidToken_ThenNotFoundIsReturned() + { + var result = await _controller.AcceptInvite("not-a-real-token", CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenAcceptingAValidInvite_ThenOkIsReturnedAndTheUserBecomesAMember() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + var link = await _controller.GetInviteLink(orgId, CancellationToken.None); + var token = ((InviteTokenResponse)((OkObjectResult)link.Result!).Value!).Token; + + Unauthenticate(); + var identity = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, "2")], "TestAuth"); + _controller.ControllerContext.HttpContext.User = new ClaimsPrincipal(identity); + + var result = await _controller.AcceptInvite(token, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_members.Any(m => m.UserId == 2), Is.True); + } + + [Test] + public async Task WhenRemovingUserFromAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.RemoveUser(999, 2, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenRemovingAUser_ThenNoContentIsReturnedAndTheyAreNoLongerAMember() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + await _controller.InviteUser(orgId, new InviteUserRequest(2, "User"), CancellationToken.None); + + var result = await _controller.RemoveUser(orgId, 2, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_members.Any(m => m.UserId == 2), Is.False); + } + + [Test] + public async Task WhenListingWebhooksForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.ListWebhooks(999, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenCreatingAWebhookForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.CreateWebhook(999, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenCreatingAWebhook_ThenACreatedResultWithTheWebhookIsReturned() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.CreateWebhook(orgId, new CreateWebhookRequest("https://example.com", "secret", true), CancellationToken.None); + + var webhookCreated = result.Result as CreatedAtActionResult; + Assert.That(webhookCreated, Is.Not.Null); + Assert.That(((WebhookResponse)webhookCreated!.Value!).Url, Is.EqualTo("https://example.com")); + } + + [Test] + public async Task WhenListingWebhooksForAnOrganization_ThenTheyAreReturned() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + await _controller.CreateWebhook(orgId, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None); + + var result = await _controller.ListWebhooks(orgId, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var webhooks = (IReadOnlyList)ok!.Value!; + Assert.That(webhooks, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenUpdatingAWebhookForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.UpdateWebhook(999, 1, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAWebhookThatDoesNotExist_ThenNotFoundIsReturned() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.UpdateWebhook(orgId, 999, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAWebhookThatBelongsToADifferentOrganization_ThenNotFoundIsReturned() + { + var org1 = await _controller.Create(new CreateOrganizationRequest("Org 1"), CancellationToken.None); + var org1Id = ((OrganizationResponse)((CreatedAtActionResult)org1.Result!).Value!).Id; + var org2 = await _controller.Create(new CreateOrganizationRequest("Org 2"), CancellationToken.None); + var org2Id = ((OrganizationResponse)((CreatedAtActionResult)org2.Result!).Value!).Id; + var webhook = await _controller.CreateWebhook(org1Id, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None); + var webhookId = ((WebhookResponse)((CreatedAtActionResult)webhook.Result!).Value!).Id; + + var result = await _controller.UpdateWebhook(org2Id, webhookId, new CreateWebhookRequest("https://other.com", null, false), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAWebhook_ThenItIsChanged() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + var webhook = await _controller.CreateWebhook(orgId, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None); + var webhookId = ((WebhookResponse)((CreatedAtActionResult)webhook.Result!).Value!).Id; + + var result = await _controller.UpdateWebhook(orgId, webhookId, new CreateWebhookRequest("https://updated.com", null, false), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(((WebhookResponse)ok!.Value!).Url, Is.EqualTo("https://updated.com")); + } + + [Test] + public async Task WhenDeletingAWebhookForAnOrganizationThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.DeleteWebhook(999, 1, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingAWebhookThatBelongsToADifferentOrganization_ThenNotFoundIsReturned() + { + var org1 = await _controller.Create(new CreateOrganizationRequest("Org 1"), CancellationToken.None); + var org1Id = ((OrganizationResponse)((CreatedAtActionResult)org1.Result!).Value!).Id; + var org2 = await _controller.Create(new CreateOrganizationRequest("Org 2"), CancellationToken.None); + var org2Id = ((OrganizationResponse)((CreatedAtActionResult)org2.Result!).Value!).Id; + var webhook = await _controller.CreateWebhook(org1Id, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None); + var webhookId = ((WebhookResponse)((CreatedAtActionResult)webhook.Result!).Value!).Id; + + var result = await _controller.DeleteWebhook(org2Id, webhookId, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingAWebhook_ThenItIsRemoved() + { + var created = await _controller.Create(new CreateOrganizationRequest("My Org"), CancellationToken.None); + var orgId = ((OrganizationResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + var webhook = await _controller.CreateWebhook(orgId, new CreateWebhookRequest("https://example.com", null, true), CancellationToken.None); + var webhookId = ((WebhookResponse)((CreatedAtActionResult)webhook.Result!).Value!).Id; + + var result = await _controller.DeleteWebhook(orgId, webhookId, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_webhooks.Any(w => w.Id == webhookId), Is.False); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Projects/CreateProjectRequestValidatorTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Projects/CreateProjectRequestValidatorTests.cs new file mode 100644 index 0000000..e03059c --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Projects/CreateProjectRequestValidatorTests.cs @@ -0,0 +1,45 @@ +using MicCheck.Api.Projects; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Projects; + +[TestFixture] +public class CreateProjectRequestValidatorTests +{ + private CreateProjectRequestValidator _validator = null!; + + [SetUp] + public void SetUp() => _validator = new CreateProjectRequestValidator(); + + [Test] + public void WhenTheRequestIsValid_ThenValidationSucceeds() + { + var result = _validator.Validate(new CreateProjectRequest("My Project", 1)); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenNameIsEmpty_ThenValidationFails() + { + var result = _validator.Validate(new CreateProjectRequest("", 1)); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenNameExceedsMaximumLength_ThenValidationFails() + { + var result = _validator.Validate(new CreateProjectRequest(new string('a', 201), 1)); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenOrganizationIdIsZeroOrLess_ThenValidationFails() + { + var result = _validator.Validate(new CreateProjectRequest("My Project", 0)); + + Assert.That(result.IsValid, Is.False); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectResponseTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectResponseTests.cs new file mode 100644 index 0000000..c9a8354 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectResponseTests.cs @@ -0,0 +1,45 @@ +using MicCheck.Api.Common.Security.Authorization; +using MicCheck.Api.Projects; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Projects; + +[TestFixture] +public class ProjectResponseTests +{ + [Test] + public void WhenMappingAProject_ThenAllFieldsAreCopied() + { + var project = new Project { Id = 1, Name = "My Project", OrganizationId = 5, HideDisabledFlags = true, CreatedAt = DateTimeOffset.UtcNow }; + + var response = ProjectResponse.From(project); + + Assert.That(response.Id, Is.EqualTo(1)); + Assert.That(response.Name, Is.EqualTo("My Project")); + Assert.That(response.OrganizationId, Is.EqualTo(5)); + Assert.That(response.HideDisabledFlags, Is.True); + } +} + +[TestFixture] +public class UserPermissionResponseTests +{ + [Test] + public void WhenMappingAUserProjectPermission_ThenPermissionsAreMappedToStrings() + { + var permission = new UserProjectPermission + { + UserId = 1, + ProjectId = 2, + IsAdmin = true, + Permissions = [ProjectPermission.ViewProject, ProjectPermission.EditFeature] + }; + + var response = UserPermissionResponse.From(permission); + + Assert.That(response.UserId, Is.EqualTo(1)); + Assert.That(response.ProjectId, Is.EqualTo(2)); + Assert.That(response.IsAdmin, Is.True); + Assert.That(response.Permissions, Is.EqualTo(new[] { "ViewProject", "EditFeature" })); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectServiceTests.cs new file mode 100644 index 0000000..1c04bfc --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectServiceTests.cs @@ -0,0 +1,168 @@ +using MicCheck.Api.Audit; +using MicCheck.Api.Common.Security.Authorization; +using MicCheck.Api.Data; +using MicCheck.Api.Organizations; +using MicCheck.Api.Projects; +using MicCheck.Api.Tests.Unit.TestSupport; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Projects; + +[TestFixture] +public class ProjectServiceTests +{ + private Mock _db = null!; + private List _projects = null!; + private List _permissions = null!; + private ProjectService _service = null!; + private const int OrganizationId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + + _db.SetupDbSet(c => c.Organizations, [ + new Organization { Id = OrganizationId, Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow } + ]); + _projects = []; + _db.SetupDbSetWithGeneratedIds(c => c.Projects, _projects); + _permissions = []; + _db.SetupDbSet(c => c.UserProjectPermissions, _permissions); + + var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); + var auditService = new Mock(_db.Object, null!, webhookQueue); + auditService.Setup(a => a.LogAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + _service = new ProjectService(_db.Object, auditService.Object); + } + + [Test] + public async Task WhenCreatingAProject_ThenItIsPersistedWithTheGivenOrganization() + { + var project = await _service.CreateAsync(OrganizationId, "My Project"); + + Assert.That(project.Name, Is.EqualTo("My Project")); + Assert.That(project.OrganizationId, Is.EqualTo(OrganizationId)); + Assert.That(_projects, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenListingProjectsForAnOrganization_ThenOnlyThatOrganizationsProjectsAreReturned() + { + await _service.CreateAsync(OrganizationId, "Project A"); + await _service.CreateAsync(999, "Other Org Project"); + + var projects = await _service.ListByOrganizationAsync(OrganizationId); + + Assert.That(projects, Has.Count.EqualTo(1)); + Assert.That(projects[0].Name, Is.EqualTo("Project A")); + } + + [Test] + public async Task WhenFindingAProjectByIdThatExists_ThenItIsReturned() + { + var project = await _service.CreateAsync(OrganizationId, "My Project"); + + var found = await _service.FindByIdAsync(project.Id); + + Assert.That(found, Is.Not.Null); + Assert.That(found!.Id, Is.EqualTo(project.Id)); + } + + [Test] + public async Task WhenFindingAProjectByIdThatDoesNotExist_ThenNullIsReturned() + { + var found = await _service.FindByIdAsync(999); + + Assert.That(found, Is.Null); + } + + [Test] + public void WhenUpdatingAProjectThatDoesNotExist_ThenKeyNotFoundExceptionIsThrown() + { + Assert.ThrowsAsync(() => _service.UpdateAsync(999, "renamed", true)); + } + + [Test] + public async Task WhenUpdatingAProject_ThenNameAndHideDisabledFlagsAreChanged() + { + var project = await _service.CreateAsync(OrganizationId, "Old Name"); + + var updated = await _service.UpdateAsync(project.Id, "New Name", true); + + Assert.That(updated.Name, Is.EqualTo("New Name")); + Assert.That(updated.HideDisabledFlags, Is.True); + } + + [Test] + public void WhenDeletingAProjectThatDoesNotExist_ThenNoExceptionIsThrown() + { + Assert.DoesNotThrowAsync(() => _service.DeleteAsync(999)); + } + + [Test] + public async Task WhenDeletingAProject_ThenItIsRemovedFromTheDatabase() + { + var project = await _service.CreateAsync(OrganizationId, "My Project"); + + await _service.DeleteAsync(project.Id); + + Assert.That(_projects.Any(p => p.Id == project.Id), Is.False); + } + + [Test] + public async Task WhenListingUserPermissionsForAProject_ThenOnlyThatProjectsPermissionsAreReturned() + { + _permissions.Add(new UserProjectPermission { ProjectId = 1, UserId = 1 }); + _permissions.Add(new UserProjectPermission { ProjectId = 999, UserId = 2 }); + + var perms = await _service.ListUserPermissionsAsync(1); + + Assert.That(perms, Has.Count.EqualTo(1)); + Assert.That(perms[0].UserId, Is.EqualTo(1)); + } + + [Test] + public async Task WhenSettingUserPermissionsForAUserWithNoExistingPermissions_ThenANewRecordIsCreated() + { + var perm = await _service.SetUserPermissionsAsync(1, userId: 5, isAdmin: true, [ProjectPermission.ViewProject]); + + Assert.That(perm.IsAdmin, Is.True); + Assert.That(perm.Permissions, Does.Contain(ProjectPermission.ViewProject)); + Assert.That(_permissions, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenSettingUserPermissionsForAUserThatAlreadyHasPermissions_ThenTheExistingRecordIsUpdated() + { + await _service.SetUserPermissionsAsync(1, userId: 5, isAdmin: false, [ProjectPermission.ViewProject]); + + var updated = await _service.SetUserPermissionsAsync(1, userId: 5, isAdmin: true, [ProjectPermission.DeleteFeature]); + + Assert.That(_permissions, Has.Count.EqualTo(1)); + Assert.That(updated.IsAdmin, Is.True); + Assert.That(updated.Permissions, Is.EqualTo(new[] { ProjectPermission.DeleteFeature })); + } + + [Test] + public void WhenRemovingUserPermissionsThatDoNotExist_ThenNoExceptionIsThrown() + { + Assert.DoesNotThrowAsync(() => _service.RemoveUserPermissionsAsync(1, 5)); + } + + [Test] + public async Task WhenRemovingUserPermissions_ThenTheRecordIsRemoved() + { + await _service.SetUserPermissionsAsync(1, userId: 5, isAdmin: true, [ProjectPermission.ViewProject]); + + await _service.RemoveUserPermissionsAsync(1, 5); + + Assert.That(_permissions, Is.Empty); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectsControllerTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectsControllerTests.cs new file mode 100644 index 0000000..33c67e4 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Projects/ProjectsControllerTests.cs @@ -0,0 +1,236 @@ +using MicCheck.Api.Audit; +using MicCheck.Api.Common; +using MicCheck.Api.Common.Security.Authorization; +using MicCheck.Api.Data; +using MicCheck.Api.Organizations; +using MicCheck.Api.Projects; +using MicCheck.Api.Tests.Unit.TestSupport; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Projects; + +[TestFixture] +public class ProjectsControllerTests +{ + private Mock _db = null!; + private List _projects = null!; + private List _permissions = null!; + private ProjectsController _controller = null!; + private const int OrganizationId = 1; + + [SetUp] + public void SetUp() + { + _db = new Mock(); + + _db.SetupDbSet(c => c.Organizations, [ + new Organization { Id = OrganizationId, Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow } + ]); + _projects = []; + _db.SetupDbSetWithGeneratedIds(c => c.Projects, _projects); + _permissions = []; + _db.SetupDbSet(c => c.UserProjectPermissions, _permissions); + + var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); + var auditService = new Mock(_db.Object, null!, webhookQueue); + auditService.Setup(a => a.LogAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + _controller = new ProjectsController(new ProjectService(_db.Object, auditService.Object)); + } + + [Test] + public async Task WhenListingProjectsForAnOrganization_ThenTheyAreReturnedInAPage() + { + await _controller.Create(new CreateProjectRequest("Project A", OrganizationId), CancellationToken.None); + await _controller.Create(new CreateProjectRequest("Project B", OrganizationId), CancellationToken.None); + + var result = await _controller.List(OrganizationId); + + var ok = result.Result as OkObjectResult; + var page = (PaginatedResponse)ok!.Value!; + Assert.That(page.Count, Is.EqualTo(2)); + Assert.That(page.Results, Has.Count.EqualTo(2)); + } + + [Test] + public async Task WhenListingProjectsWithAPageSizeAboveTheMaximum_ThenItIsClampedTo100() + { + for (var i = 0; i < 3; i++) + await _controller.Create(new CreateProjectRequest($"Project {i}", OrganizationId), CancellationToken.None); + + var result = await _controller.List(OrganizationId, page: 1, pageSize: 1000); + + var ok = result.Result as OkObjectResult; + var page = (PaginatedResponse)ok!.Value!; + Assert.That(page.Results, Has.Count.EqualTo(3)); + } + + [Test] + public async Task WhenCreatingAProject_ThenACreatedResultWithTheProjectIsReturned() + { + var result = await _controller.Create(new CreateProjectRequest("My Project", OrganizationId), CancellationToken.None); + + var created = result.Result as CreatedAtActionResult; + Assert.That(created, Is.Not.Null); + Assert.That(((ProjectResponse)created!.Value!).Name, Is.EqualTo("My Project")); + } + + [Test] + public async Task WhenGettingAProjectByIdThatExists_ThenItIsReturned() + { + var created = await _controller.Create(new CreateProjectRequest("My Project", OrganizationId), CancellationToken.None); + var projectId = ((ProjectResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.GetById(projectId, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + Assert.That(ok, Is.Not.Null); + Assert.That(((ProjectResponse)ok!.Value!).Id, Is.EqualTo(projectId)); + } + + [Test] + public async Task WhenGettingAProjectByIdThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.GetById(999, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAProjectThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.Update(999, new UpdateProjectRequest("renamed", false), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingAProject_ThenTheUpdatedProjectIsReturned() + { + var created = await _controller.Create(new CreateProjectRequest("Old Name", OrganizationId), CancellationToken.None); + var projectId = ((ProjectResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.Update(projectId, new UpdateProjectRequest("New Name", true), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var response = (ProjectResponse)ok!.Value!; + Assert.That(response.Name, Is.EqualTo("New Name")); + Assert.That(response.HideDisabledFlags, Is.True); + } + + [Test] + public async Task WhenDeletingAProjectThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.Delete(999, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingAProject_ThenNoContentIsReturnedAndItIsRemoved() + { + var created = await _controller.Create(new CreateProjectRequest("My Project", OrganizationId), CancellationToken.None); + var projectId = ((ProjectResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.Delete(projectId, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_projects.Any(p => p.Id == projectId), Is.False); + } + + [Test] + public async Task WhenListingUserPermissionsForAProjectThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.ListUserPermissions(999, CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenListingUserPermissionsForAProject_ThenTheyAreReturned() + { + var created = await _controller.Create(new CreateProjectRequest("My Project", OrganizationId), CancellationToken.None); + var projectId = ((ProjectResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + await _controller.CreateUserPermissions(projectId, new SetUserPermissionsRequest(5, true, ["ViewProject"]), CancellationToken.None); + + var result = await _controller.ListUserPermissions(projectId, CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var perms = (IReadOnlyList)ok!.Value!; + Assert.That(perms, Has.Count.EqualTo(1)); + Assert.That(perms[0].UserId, Is.EqualTo(5)); + } + + [Test] + public async Task WhenCreatingUserPermissionsForAProjectThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.CreateUserPermissions(999, new SetUserPermissionsRequest(5, true, []), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenCreatingUserPermissions_ThenThePermissionsAreParsedAndPersisted() + { + var created = await _controller.Create(new CreateProjectRequest("My Project", OrganizationId), CancellationToken.None); + var projectId = ((ProjectResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + + var result = await _controller.CreateUserPermissions( + projectId, new SetUserPermissionsRequest(5, true, ["ViewProject", "EditFeature"]), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var response = (UserPermissionResponse)ok!.Value!; + Assert.That(response.Permissions, Is.EqualTo(new[] { "ViewProject", "EditFeature" })); + } + + [Test] + public async Task WhenUpdatingUserPermissionsForAProjectThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.UpdateUserPermissions(999, 5, new SetUserPermissionsRequest(5, true, []), CancellationToken.None); + + Assert.That(result.Result, Is.InstanceOf()); + } + + [Test] + public async Task WhenUpdatingUserPermissions_ThenTheExistingRecordIsChanged() + { + var created = await _controller.Create(new CreateProjectRequest("My Project", OrganizationId), CancellationToken.None); + var projectId = ((ProjectResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + await _controller.CreateUserPermissions(projectId, new SetUserPermissionsRequest(5, false, ["ViewProject"]), CancellationToken.None); + + var result = await _controller.UpdateUserPermissions(projectId, 5, new SetUserPermissionsRequest(5, true, ["DeleteFeature"]), CancellationToken.None); + + var ok = result.Result as OkObjectResult; + var response = (UserPermissionResponse)ok!.Value!; + Assert.That(response.IsAdmin, Is.True); + Assert.That(response.Permissions, Is.EqualTo(new[] { "DeleteFeature" })); + Assert.That(_permissions, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenDeletingUserPermissionsForAProjectThatDoesNotExist_ThenNotFoundIsReturned() + { + var result = await _controller.DeleteUserPermissions(999, 5, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task WhenDeletingUserPermissions_ThenTheRecordIsRemoved() + { + var created = await _controller.Create(new CreateProjectRequest("My Project", OrganizationId), CancellationToken.None); + var projectId = ((ProjectResponse)((CreatedAtActionResult)created.Result!).Value!).Id; + await _controller.CreateUserPermissions(projectId, new SetUserPermissionsRequest(5, true, ["ViewProject"]), CancellationToken.None); + + var result = await _controller.DeleteUserPermissions(projectId, 5, CancellationToken.None); + + Assert.That(result, Is.InstanceOf()); + Assert.That(_permissions, Is.Empty); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Projects/SetUserPermissionsRequestValidatorTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Projects/SetUserPermissionsRequestValidatorTests.cs new file mode 100644 index 0000000..4b40f3c --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Projects/SetUserPermissionsRequestValidatorTests.cs @@ -0,0 +1,45 @@ +using MicCheck.Api.Projects; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Projects; + +[TestFixture] +public class SetUserPermissionsRequestValidatorTests +{ + private SetUserPermissionsRequestValidator _validator = null!; + + [SetUp] + public void SetUp() => _validator = new SetUserPermissionsRequestValidator(); + + [Test] + public void WhenTheRequestIsValid_ThenValidationSucceeds() + { + var result = _validator.Validate(new SetUserPermissionsRequest(1, true, ["ViewProject", "EditFeature"])); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenUserIdIsZeroOrLess_ThenValidationFails() + { + var result = _validator.Validate(new SetUserPermissionsRequest(0, true, [])); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenAPermissionIsInvalid_ThenValidationFails() + { + var result = _validator.Validate(new SetUserPermissionsRequest(1, false, ["NotAPermission"])); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenPermissionsListIsEmpty_ThenValidationSucceeds() + { + var result = _validator.Validate(new SetUserPermissionsRequest(1, true, [])); + + Assert.That(result.IsValid, Is.True); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Projects/UpdateProjectRequestValidatorTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Projects/UpdateProjectRequestValidatorTests.cs new file mode 100644 index 0000000..04e0362 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Projects/UpdateProjectRequestValidatorTests.cs @@ -0,0 +1,37 @@ +using MicCheck.Api.Projects; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Projects; + +[TestFixture] +public class UpdateProjectRequestValidatorTests +{ + private UpdateProjectRequestValidator _validator = null!; + + [SetUp] + public void SetUp() => _validator = new UpdateProjectRequestValidator(); + + [Test] + public void WhenTheRequestIsValid_ThenValidationSucceeds() + { + var result = _validator.Validate(new UpdateProjectRequest("Renamed", true)); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void WhenNameIsEmpty_ThenValidationFails() + { + var result = _validator.Validate(new UpdateProjectRequest("", false)); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void WhenNameExceedsMaximumLength_ThenValidationFails() + { + var result = _validator.Validate(new UpdateProjectRequest(new string('a', 201), false)); + + Assert.That(result.IsValid, Is.False); + } +}