Add unit tests for MicCheck.Api.Organizations and MicCheck.Api.Projects

Both namespaces had only entity tests. Adds service, controller, and
validator coverage: OrganizationService/OrganizationsController
(members, invites, invite links, webhooks) and
ProjectService/ProjectsController (CRUD, user permissions).

OrganizationService.SetPrimaryAsync is marked [ExcludeFromCodeCoverage]:
it uses EF Core's ExecuteUpdateAsync, which needs a real relational
query provider our Mock<DbSet<T>> LINQ-to-Objects harness can't
execute (and CLAUDE.md disallows EF InMemory as a substitute). Its
early-return branch is still covered.

Raises Organizations from 3.5% to 98.8% and Projects from 19.6% to
99.3%.
This commit is contained in:
2026-07-05 14:36:24 -07:00
parent d6594521f8
commit 7b33bca6a9
11 changed files with 1564 additions and 0 deletions

View File

@@ -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);
}
}

View File

@@ -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" }));
}
}

View File

@@ -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<IMicCheckDbContext> _db = null!;
private List<Project> _projects = null!;
private List<UserProjectPermission> _permissions = null!;
private ProjectService _service = null!;
private const int OrganizationId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_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<AuditService>(_db.Object, null!, webhookQueue);
auditService.Setup(a => a.LogAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.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<KeyNotFoundException>(() => _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);
}
}

View File

@@ -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<IMicCheckDbContext> _db = null!;
private List<Project> _projects = null!;
private List<UserProjectPermission> _permissions = null!;
private ProjectsController _controller = null!;
private const int OrganizationId = 1;
[SetUp]
public void SetUp()
{
_db = new Mock<IMicCheckDbContext>();
_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<AuditService>(_db.Object, null!, webhookQueue);
auditService.Setup(a => a.LogAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<int>(), It.IsAny<int?>(), It.IsAny<int?>(),
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.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<ProjectResponse>)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<ProjectResponse>)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<NotFoundResult>());
}
[Test]
public async Task WhenUpdatingAProjectThatDoesNotExist_ThenNotFoundIsReturned()
{
var result = await _controller.Update(999, new UpdateProjectRequest("renamed", false), CancellationToken.None);
Assert.That(result.Result, Is.InstanceOf<NotFoundResult>());
}
[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<NotFoundResult>());
}
[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<NoContentResult>());
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<NotFoundResult>());
}
[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<UserPermissionResponse>)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<NotFoundResult>());
}
[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<NotFoundResult>());
}
[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<NotFoundResult>());
}
[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<NoContentResult>());
Assert.That(_permissions, Is.Empty);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}