diff --git a/Api.SeaHavenIndustries.Tests/AccountOwnerDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/AccountOwnerDataServiceTests.cs new file mode 100644 index 0000000..fad450d --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/AccountOwnerDataServiceTests.cs @@ -0,0 +1,60 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class AccountOwnerDataServiceTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + [Fact] + public async Task EnsureConfiguredOwnerAsync_MarksOnlyConfiguredUser() + { + await using var context = NewContext(); + context.Users.AddRange( + new ApplicationUser { Id = "old-owner", UserName = "old@example.com", IsAccountOwner = true }, + new ApplicationUser { Id = "configured-owner", UserName = "configured@example.com" }); + await context.SaveChangesAsync(); + + var service = new AccountOwnerDataService(context); + (await service.EnsureConfiguredOwnerAsync(" configured-owner ", CancellationToken.None)).Should().BeTrue(); + + (await context.Users.SingleAsync(user => user.Id == "old-owner")).IsAccountOwner.Should().BeFalse(); + (await context.Users.SingleAsync(user => user.Id == "configured-owner")).IsAccountOwner.Should().BeTrue(); + } + + [Fact] + public async Task EnsureConfiguredOwnerAsync_WithNoConfiguration_ClearsExistingOwner() + { + await using var context = NewContext(); + context.Users.Add(new ApplicationUser { Id = "owner", UserName = "owner@example.com", IsAccountOwner = true }); + await context.SaveChangesAsync(); + + var service = new AccountOwnerDataService(context); + (await service.EnsureConfiguredOwnerAsync(null, CancellationToken.None)).Should().BeTrue(); + (await service.EnsureConfiguredOwnerAsync(" ", CancellationToken.None)).Should().BeFalse(); + + (await context.Users.SingleAsync()).IsAccountOwner.Should().BeFalse(); + } + + [Fact] + public async Task EnsureConfiguredOwnerAsync_RejectsUnknownUser() + { + await using var context = NewContext(); + var service = new AccountOwnerDataService(context); + + var action = () => service.EnsureConfiguredOwnerAsync("missing", CancellationToken.None); + + await action.Should().ThrowAsync() + .WithMessage("The configured account owner user was not found."); + } +} diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index 1bfe088..2cc73ee 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -270,11 +270,13 @@ public class DashboardServiceTests 1, CancellationToken.None); - result.TotalDispatchers.Should().Be(1); - result.Items.Should().ContainSingle(); - result.Items[0].DispatcherName.Should().Be("Ada One"); + result.TotalDispatchers.Should().Be(2); + result.Items.Select(item => item.DispatcherName) + .Should().ContainInOrder("Ada One", "Ben Two"); result.Items[0].TotalCount.Should().Be(2); result.Items[0].OpenCount.Should().Be(1); + result.Items[1].TotalCount.Should().Be(0); + result.Items[1].OpenCount.Should().Be(0); result.PageSize.Should().Be(10); var adminResult = await NewService(ctx).GetWorkloadAsync( @@ -444,6 +446,275 @@ public class DashboardServiceTests result.Items[0].RescheduleRate.Should().Be(50); } + [Fact] + public async Task GetStatsAsync_DispatcherQuerySelectionCannotBroadenScope() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }); + ctx.SaveChanges(); + + var query = new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" }; + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, "dispatcher-1", "Dispatcher"), query, CancellationToken.None); + + stats.Total.Should().Be(2); + stats.Open.Should().Be(2); + } + + [Fact] + public async Task GetStatsAsync_AdminCanSelectIndividualDispatcherOrMine() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Done", AssignTo = "admin-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }); + ctx.SaveChanges(); + + var service = NewService(ctx); + + var selected = await service.GetStatsAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" }, + CancellationToken.None); + selected.Total.Should().Be(3); + selected.Open.Should().Be(3); + + var mine = await service.GetStatsAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO { DispatcherId = "mine" }, + CancellationToken.None); + mine.Total.Should().Be(1); + mine.Completed.Should().Be(1); + + var all = await service.GetStatsAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO(), + CancellationToken.None); + all.Total.Should().Be(4); + } + + [Fact] + public async Task GetStatsAsync_ManagerSelectionScopesStats() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, "manager-1", "Manager"), + new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" }, + CancellationToken.None); + + stats.Total.Should().Be(1); + } + + [Fact] + public async Task GetStatsAsync_SchedulerSelectionScopesStats() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, "scheduler-1", "Scheduler"), + new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" }, + CancellationToken.None); + + stats.Total.Should().Be(1); + } + + [Fact] + public async Task GetDashboardAsync_UnauthorizedRoleFailsClosed() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var user = AccountUser(1, "tech-1", "Technician"); + + var statsAct = () => service.GetStatsAsync( + user, new DashboardStatsQueryDTO(), CancellationToken.None); + var statsException = await statsAct.Should().ThrowAsync(); + statsException.Which.Code.Should().Be("Forbidden"); + + var workloadAct = () => service.GetWorkloadAsync( + user, new DashboardStatsQueryDTO(), 1, CancellationToken.None); + var workloadException = await workloadAct.Should().ThrowAsync(); + workloadException.Which.Code.Should().Be("Forbidden"); + + var performanceAct = () => service.GetPerformanceAsync( + user, new DashboardStatsQueryDTO(), 1, CancellationToken.None); + var performanceException = await performanceAct.Should().ThrowAsync(); + performanceException.Which.Code.Should().Be("Forbidden"); + + var regionsAct = () => service.GetRegionsAsync( + user, new DashboardStatsQueryDTO(), CancellationToken.None); + var regionsException = await regionsAct.Should().ThrowAsync(); + regionsException.Which.Code.Should().Be("Forbidden"); + + var trendAct = () => service.GetTrendAsync( + user, new DashboardTrendQueryDTO(), CancellationToken.None); + var trendException = await trendAct.Should().ThrowAsync(); + trendException.Which.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task GetWorkloadAsync_IndividualSelectionPreservesRosterWithZeroRows() + { + using var ctx = NewContext(); + ctx.Roles.Add(new IdentityRole + { + Id = "dispatcher-role", + Name = "Dispatcher", + NormalizedName = "DISPATCHER" + }); + ctx.Users.AddRange( + new ApplicationUser { Id = "dispatcher-1", FirstName = "Ada", LastName = "One" }, + new ApplicationUser { Id = "dispatcher-2", FirstName = "Ben", LastName = "Two" }); + ctx.UserRoles.AddRange( + new IdentityUserRole { UserId = "dispatcher-1", RoleId = "dispatcher-role" }, + new IdentityUserRole { UserId = "dispatcher-2", RoleId = "dispatcher-role" }); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", Status = "Open" }, + new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", Status = "Open" }); + ctx.SaveChanges(); + + var workload = await NewService(ctx).GetWorkloadAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO { DispatcherId = "dispatcher-1" }, + 1, + CancellationToken.None); + + workload.TotalDispatchers.Should().Be(2); + workload.Items.Should().HaveCount(2); + workload.Items[0].DispatcherId.Should().Be("dispatcher-1"); + workload.Items[0].TotalCount.Should().Be(2); + workload.Items[0].OpenCount.Should().Be(2); + workload.Items[1].DispatcherId.Should().Be("dispatcher-2"); + workload.Items[1].TotalCount.Should().Be(0); + workload.Items[1].OpenCount.Should().Be(0); + workload.Page.Should().Be(1); + workload.PageSize.Should().Be(10); + + var performance = await NewService(ctx).GetPerformanceAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO { DispatcherId = "dispatcher-1" }, + 1, + CancellationToken.None); + + performance.TotalDispatchers.Should().Be(2); + performance.Items.Should().HaveCount(2); + performance.Items[0].DispatcherId.Should().Be("dispatcher-1"); + performance.Items[0].AssignedCount.Should().Be(2); + performance.Items[1].DispatcherId.Should().Be("dispatcher-2"); + performance.Items[1].AssignedCount.Should().Be(0); + performance.Items[1].CompletedCount.Should().Be(0); + } + + [Fact] + public async Task GetPerformanceAsync_PopulatesAssignedAndCompletedCounts() + { + using var ctx = NewContext(); + ctx.Roles.Add(new IdentityRole + { + Id = "dispatcher-role", + Name = "Dispatcher", + NormalizedName = "DISPATCHER" + }); + ctx.Users.Add(new ApplicationUser { Id = "dispatcher-1", FirstName = "Ada", LastName = "One" }); + ctx.UserRoles.Add(new IdentityUserRole + { + UserId = "dispatcher-1", + RoleId = "dispatcher-role" + }); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed + }, + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled + }, + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled + }); + ctx.SaveChanges(); + + var result = await NewService(ctx).GetPerformanceAsync( + AccountUser(1), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + result.Items.Should().ContainSingle(); + result.Items[0].AssignedCount.Should().Be(3); + result.Items[0].CompletedCount.Should().Be(1); + } + + [Fact] + public async Task GetStatsAsync_StatusDistributionBucketsResolvedStatuses() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled + }, + new WorkOrder { AccountId = 1, Status = "In Progress" }, + new WorkOrder { AccountId = 1, Status = "Done" }, + new WorkOrder { AccountId = 1, Status = "Done" }, + new WorkOrder { AccountId = 1, Status = "Unparseable" }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO(), + CancellationToken.None); + + stats.StatusDistribution.Should().HaveCount(4); + stats.StatusDistribution + .Select(bucket => (bucket.Status, bucket.Count)) + .Should().BeEquivalentTo(new[] + { + (Status: "Completed", Count: 2), + (Status: "In Progress", Count: 1), + (Status: "Scheduled", Count: 1), + (Status: "Unknown", Count: 1) + }); + stats.StatusDistribution.Sum(bucket => bucket.Count).Should().Be(5); + } + + [Fact] + public async Task GetStatsAsync_StatusDistributionIsEmptyWithoutWorkOrders() + { + using var ctx = NewContext(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO(), + CancellationToken.None); + + stats.StatusDistribution.Should().BeEmpty(); + } + [Fact] public async Task GetTrendAsync_DailyBucketsClassifyByCalendarScheduledDate() { diff --git a/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs new file mode 100644 index 0000000..5f5204b --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs @@ -0,0 +1,404 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public sealed class TeamMemberServiceTests +{ + [Fact] + public async Task Create_DispatcherRequiresAtLeastOneServiceArea() + { + var userManager = UserManager(); + var roleManager = RoleManager(); + var service = new TeamMemberService( + userManager.Object, + roleManager.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()); + + var result = await service.CreateAsync( + ValidRequest() with { ServiceAreas = Array.Empty() }, + Admin(), + CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("At least one service area is required for a Dispatcher."); + userManager.Verify(manager => manager.CreateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_RejectsUnknownColorAndPermission() + { + var service = NewService(out var userManager, out _, out _, out _, out _, out _); + + var badColor = await service.CreateAsync( + ValidRequest() with { Color = "#000000" }, + Admin(), + CancellationToken.None); + var badPermission = await service.CreateAsync( + ValidRequest() with + { + PermissionOverrides = new Dictionary + { + ["not-a-permission"] = UserPermissionState.Allow + } + }, + Admin(), + CancellationToken.None); + + badColor.Error.Should().Be("Color must be selected from the accessible palette."); + badPermission.Error.Should().Be("Unknown permission key: not-a-permission."); + userManager.Verify(manager => manager.CreateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_PersistsPendingUserWithoutPasswordAndStoresAreasAndOverrides() + { + var service = NewService(out var userManager, out var roleManager, out var areas, out var overrides, out _, out _); + ApplicationUser? created = null; + userManager + .Setup(manager => manager.CreateAsync(It.IsAny())) + .Callback(user => + { + user.Id = "new-user"; + created = user; + }) + .ReturnsAsync(IdentityResult.Success); + userManager + .Setup(manager => manager.FindByEmailAsync(It.IsAny())) + .ReturnsAsync((ApplicationUser?)null); + userManager + .Setup(manager => manager.AddToRoleAsync(It.IsAny(), "Dispatcher")) + .ReturnsAsync(IdentityResult.Success); + roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true); + + var result = await service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None); + + result.Success.Should().BeTrue(); + created.Should().NotBeNull(); + created!.PasswordHash.Should().BeNull(); + created.EmailConfirmed.Should().BeFalse(); + created.PendingRegistration.Should().BeTrue(); + created.PhoneNumber.Should().Be("555-0100"); + result.Member!.ServiceAreas.Should().Equal("East", "West"); + areas.Verify(data => data.ReplaceAsync("new-user", It.Is>(value => value.SequenceEqual(new[] { "East", "West" })), It.IsAny()), Times.Once); + overrides.Verify(data => data.SetOverridesAsync("new-user", It.Is>(value => value["deleteSites"] == UserPermissionState.Allow), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Update_ProtectsAccountOwnerFromRoleChangeAndDeactivation() + { + var service = NewService( + out var userManager, + out _, + out _, + out _, + out var userData, + out _); + var owner = new ApplicationUser + { + Id = "owner", + FirstName = "Primary", + Email = "owner@example.com", + IsAccountOwner = true + }; + userManager.Setup(manager => manager.FindByIdAsync(owner.Id)).ReturnsAsync(owner); + userManager.Setup(manager => manager.GetRolesAsync(owner)).ReturnsAsync(new List { "Admin" }); + userData.Setup(data => data.IsAccountOwnerAsync(owner.Id, It.IsAny())).ReturnsAsync(true); + + var result = await service.UpdateAsync( + owner.Id, + new UpdateTeamMemberRequestDTO + { + Name = "Primary", + Role = "Dispatcher", + Color = "#F59E0B", + Email = owner.Email, + ServiceAreas = new[] { "East" }, + IsActive = false + }, + Admin(), + CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("Forbidden"); + userData.Verify(data => data.UpdateUserAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Update_ReplacesAreasAndOverridesAndReturnsFullDetail() + { + var service = NewService( + out var userManager, + out var roleManager, + out var areas, + out var overrides, + out var userData, + out var permissions); + var user = new ApplicationUser + { + Id = "dispatcher", + FirstName = "Taylor", + Email = "old@example.com", + Color = "#F59E0B", + UniqueName = "Active" + }; + userManager.Setup(manager => manager.FindByIdAsync(user.Id)).ReturnsAsync(user); + userManager.Setup(manager => manager.GetRolesAsync(user)).ReturnsAsync(new List { "Dispatcher" }); + userManager.Setup(manager => manager.FindByEmailAsync("new@example.com")).ReturnsAsync((ApplicationUser?)null); + userManager.Setup(manager => manager.SetEmailAsync(user, "new@example.com")) + .Callback((member, email) => member.Email = email) + .ReturnsAsync(IdentityResult.Success); + userManager.Setup(manager => manager.SetUserNameAsync(user, "new@example.com")) + .Callback((member, email) => member.UserName = email) + .ReturnsAsync(IdentityResult.Success); + userManager.Setup(manager => manager.UpdateSecurityStampAsync(user)) + .ReturnsAsync(IdentityResult.Success); + roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true); + areas.Setup(data => data.GetForUserAsync(user.Id, It.IsAny())) + .ReturnsAsync(new[] { "Central" }); + permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny(), It.IsAny())) + .ReturnsAsync(TeamPermissionResult.Success(new TeamPermissionProfileDTO + { + UserId = user.Id, + RoleName = "Dispatcher", + Permissions = new[] + { + new TeamPermissionValueDTO + { + PermissionKey = "createSites", + OverrideState = UserPermissionState.Deny, + IsGranted = false + } + } + })); + + var result = await service.UpdateAsync( + user.Id, + new UpdateTeamMemberRequestDTO + { + Name = "Taylor Updated", + Role = "Dispatcher", + Color = "#0D9488", + Email = "new@example.com", + Phone = "555-0101", + ServiceAreas = new[] { "West" }, + PermissionOverrides = new Dictionary + { + ["createSites"] = UserPermissionState.Deny + } + }, + Admin(), + CancellationToken.None); + + result.Success.Should().BeTrue(); + result.Member!.Name.Should().Be("Taylor Updated"); + result.Member.Email.Should().Be("new@example.com"); + result.Member.ServiceAreas.Should().Equal("Central"); + result.Member.Permissions.Should().ContainSingle(permission => permission.PermissionKey == "createSites"); + userData.Verify(data => data.UpdateUserAsync(user, It.IsAny()), Times.Once); + userManager.Verify(manager => manager.UpdateSecurityStampAsync(user), Times.Once); + areas.Verify(data => data.ReplaceAsync(user.Id, It.Is>(value => value.SequenceEqual(new[] { "West" })), It.IsAny()), Times.Once); + overrides.Verify(data => data.ClearOverridesAsync(user.Id, It.IsAny()), Times.Once); + overrides.Verify(data => data.SetOverridesAsync(user.Id, It.Is>(value => value["createSites"] == UserPermissionState.Deny), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Update_SavesUnchangedNameWithoutDuplicatingRoleLikeLastName() + { + var service = NewService( + out var userManager, + out var roleManager, + out var areas, + out _, + out var userData, + out var permissions); + var user = new ApplicationUser + { + Id = "admin", + FirstName = "Legacy", + LastName = "Manager", + Email = "legacy@example.com", + Color = "#F59E0B", + UniqueName = "Active" + }; + userManager.Setup(manager => manager.FindByIdAsync(user.Id)).ReturnsAsync(user); + userManager.Setup(manager => manager.GetRolesAsync(user)).ReturnsAsync(new List { "Admin" }); + userManager.Setup(manager => manager.FindByEmailAsync(user.Email!)).ReturnsAsync(user); + roleManager.Setup(manager => manager.RoleExistsAsync("Admin")).ReturnsAsync(true); + areas.Setup(data => data.GetForUserAsync(user.Id, It.IsAny())) + .ReturnsAsync(Array.Empty()); + permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny(), It.IsAny())) + .ReturnsAsync(TeamPermissionResult.Success(new TeamPermissionProfileDTO + { + UserId = user.Id, + Permissions = Array.Empty() + })); + + var result = await service.UpdateAsync( + user.Id, + new UpdateTeamMemberRequestDTO + { + Name = "Legacy Manager", + Role = "Admin", + Color = "#F59E0B", + Email = user.Email + }, + Admin(), + CancellationToken.None); + + result.Success.Should().BeTrue(); + user.FirstName.Should().Be("Legacy"); + user.LastName.Should().Be("Manager"); + result.Member!.Name.Should().Be("Legacy Manager"); + userData.Verify(data => data.UpdateUserAsync(user, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Update_LeavesOmittedOverridesUnchanged() + { + var service = NewService( + out var userManager, + out var roleManager, + out var areas, + out var overrides, + out var userData, + out var permissions); + var user = new ApplicationUser + { + Id = "dispatcher", + FirstName = "Taylor", + Email = "taylor@example.com", + Color = "#F59E0B", + UniqueName = "Active" + }; + userManager.Setup(manager => manager.FindByIdAsync(user.Id)).ReturnsAsync(user); + userManager.Setup(manager => manager.GetRolesAsync(user)).ReturnsAsync(new List { "Dispatcher" }); + roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true); + areas.Setup(data => data.GetForUserAsync(user.Id, It.IsAny())).ReturnsAsync(Array.Empty()); + permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny(), It.IsAny())) + .ReturnsAsync(TeamPermissionResult.Success(new TeamPermissionProfileDTO + { + UserId = user.Id, + Permissions = Array.Empty() + })); + + var result = await service.UpdateAsync( + user.Id, + new UpdateTeamMemberRequestDTO + { + Name = "Taylor", + Role = "Dispatcher", + Color = "#F59E0B", + Email = user.Email, + ServiceAreas = new[] { "East" } + }, + Admin(), + CancellationToken.None); + + result.Success.Should().BeTrue(); + overrides.Verify(data => data.ClearOverridesAsync(It.IsAny(), It.IsAny()), Times.Never); + overrides.Verify(data => data.SetOverridesAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + userData.Verify(data => data.UpdateUserAsync(user, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Create_ConcurrentDuplicateEmail_ReturnsAlreadyInUseInsteadOf500() + { + var service = NewService(out var userManager, out var roleManager, out _, out _, out _, out _); + userManager + .Setup(manager => manager.FindByEmailAsync(It.IsAny())) + .ReturnsAsync((ApplicationUser?)null); + roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true); + userManager + .Setup(manager => manager.CreateAsync(It.IsAny())) + .ThrowsAsync(new DbUpdateException("duplicate key", new Exception())); + + var result = await service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("Email is already in use."); + userManager.Verify(manager => manager.AddToRoleAsync(It.IsAny(), It.IsAny()), Times.Never); + userManager.Verify(manager => manager.DeleteAsync(It.IsAny()), Times.Never); + } + + private static TeamMemberService NewService( + out Mock> userManager, + out Mock> roleManager, + out Mock areas, + out Mock overrides, + out Mock userData, + out Mock permissions) + { + userManager = UserManager(); + roleManager = RoleManager(); + areas = new Mock(); + overrides = new Mock(); + userData = new Mock(); + permissions = new Mock(); + return new TeamMemberService( + userManager.Object, + roleManager.Object, + areas.Object, + overrides.Object, + userData.Object, + permissions.Object); + } + + private static Mock> UserManager() + { + var store = new Mock>(); + return new Mock>( + store.Object, + Microsoft.Extensions.Options.Options.Create(new IdentityOptions()), + Mock.Of>(), + Array.Empty>(), + Array.Empty>(), + Mock.Of(), + new IdentityErrorDescriber(), + Mock.Of(), + Mock.Of>>()); + } + + private static Mock> RoleManager() + { + var store = new Mock>(); + return new Mock>( + store.Object, + Array.Empty>(), + Mock.Of(), + new IdentityErrorDescriber(), + Mock.Of>>()); + } + + private static CreateTeamMemberRequestDTO ValidRequest() => new() + { + Name = "Taylor Dispatcher", + Role = "dispatcher", + Color = "#F59E0B", + Email = "taylor@example.com", + Phone = "555-0100", + ServiceAreas = new[] { "east", "West" }, + PermissionOverrides = new Dictionary + { + ["deleteSites"] = UserPermissionState.Allow + } + }; + + private static ClaimsPrincipal Admin() => + new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "test")); +} diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs index 4aa0d73..84b86bc 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs @@ -654,6 +654,32 @@ public sealed class UpliftQueueReadTests total.AutoApprovedTotal.Should().Be(0m); } + [Fact] + public async Task Exposure_AutoApprovedSumsGrantedAmountsPerCreationPath() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A"); + context.DispatchUpliftRequests.AddRange( + // Vendor-portal rows (createdby null) store the requested new NTE total: + // 1000 -> 1500 grants 500, then 1500 -> 1800 grants 300. + Request(dispatch, "NoApprovalRequired", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 1500m, currentNte: 1000m), + Request(dispatch, "NoApprovalRequired", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 1800m, currentNte: 1500m), + // Work-order-path rows (createdby set) store the granted increment: 200. + Request(dispatch, "NoApprovalRequired", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 200m, currentNte: 1800m, createdBy: "user-7")); + await context.SaveChangesAsync(); + var data = new UpliftDataService(context); + + var exposure = await data.GetApprovedExposureForWorkOrdersAsync( + new[] { workOrder.Id }, CancellationToken.None); + + // Granted exposure is 500 + 300 + 200; summing raw RequestedNTE would + // double-count whole NTE totals and report 3500. + var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrder.Id).Subject; + total.AutoApprovedTotal.Should().Be(1000m); + total.AdminApprovedTotal.Should().Be(0m); + } + // --- Read authorization (tier roles) --- [Fact] diff --git a/Api.SeaHavenIndustries.Tests/UserServiceTests.cs b/Api.SeaHavenIndustries.Tests/UserServiceTests.cs index d2364bb..916d0ec 100644 --- a/Api.SeaHavenIndustries.Tests/UserServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/UserServiceTests.cs @@ -90,6 +90,23 @@ public class UserServiceTests userData.Verify(u => u.DeleteUserWithCascadeAsync(user, It.IsAny()), Times.Once); } + [Fact] + public async Task DeleteUser_AccountOwner_ReturnsForbiddenWithoutCascade() + { + var user = IdentityTestHelpers.User("owner"); + var userData = new Mock(); + userData.Setup(u => u.GetForEditAsync("owner", It.IsAny())).ReturnsAsync(user); + userData.Setup(u => u.IsAccountOwnerAsync("owner", It.IsAny())).ReturnsAsync(true); + + var service = NewService(userData, new Mock(), out _); + + var outcome = await service.DeleteUserAsync("owner", Principal("Admin"), CancellationToken.None); + + outcome.Success.Should().BeFalse(); + outcome.Error.Should().Be(UserMutationErrors.Forbidden); + userData.Verify(u => u.DeleteUserWithCascadeAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Fact] public async Task DeleteUser_NonAdmin_ReturnsForbiddenWithoutCascade() { @@ -138,6 +155,26 @@ public class UserServiceTests outcome.Error.Should().Be(UserMutationErrors.UserNotFound); } + [Fact] + public async Task EditUser_AccountOwner_ReturnsForbiddenWithoutMutation() + { + var user = IdentityTestHelpers.User("owner"); + var userData = new Mock(); + userData.Setup(u => u.GetForEditAsync("owner", It.IsAny())).ReturnsAsync(user); + userData.Setup(u => u.IsAccountOwnerAsync("owner", It.IsAny())).ReturnsAsync(true); + + var service = NewService(userData, new Mock(), out _); + + var outcome = await service.EditUserAsync( + new EditUserRequestDTO { Id = "owner", Email = "owner@example.com", Name = "Owner", Role = "User" }, + Principal("Admin"), + CancellationToken.None); + + outcome.Success.Should().BeFalse(); + outcome.Error.Should().Be(UserMutationErrors.Forbidden); + userData.Verify(u => u.UpdateUserAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Fact] public async Task GetUserProfile_MapsCreatedDateToAddedDate() { diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index 483f891..b53c4bf 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -37,7 +37,8 @@ namespace Api.SeaHavenIndustries.Controllers dueCount = stats.DueCount, completedDueCount = stats.CompletedDueCount, completionRate = stats.CompletionRate, - averageResolutionDays = stats.AverageResolutionDays + averageResolutionDays = stats.AverageResolutionDays, + statusDistribution = stats.StatusDistribution }); } diff --git a/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs b/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs new file mode 100644 index 0000000..f6537b6 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers; + +[Authorize] +[ApiController] +[Route("api/team-members")] +public sealed class TeamMemberController : ControllerBase +{ + private readonly ITeamMemberService _teamMemberService; + + public TeamMemberController(ITeamMemberService teamMemberService) + { + _teamMemberService = teamMemberService; + } + + [HttpPost] + public async Task Create( + CreateTeamMemberRequestDTO request, + CancellationToken cancellationToken) + { + var outcome = await _teamMemberService.CreateAsync(request, User, cancellationToken); + if (!outcome.Success) + { + if (string.Equals(outcome.Error, "Forbidden", StringComparison.Ordinal)) + return Forbid(); + + return BadRequest(new { message = outcome.Error }); + } + + return Ok(outcome.Member); + } + + [HttpGet("{userId}")] + public async Task Get(string userId, CancellationToken cancellationToken) + { + var outcome = await _teamMemberService.GetAsync(userId, User, cancellationToken); + return ToActionResult(outcome); + } + + [HttpPut("{userId}")] + public async Task Update( + string userId, + UpdateTeamMemberRequestDTO request, + CancellationToken cancellationToken) + { + var outcome = await _teamMemberService.UpdateAsync(userId, request, User, cancellationToken); + return ToActionResult(outcome); + } + + private IActionResult ToActionResult(TeamMemberOperationOutcomeDTO outcome) + { + if (outcome.Success) + return Ok(outcome.Member); + + if (string.Equals(outcome.Error, "Forbidden", StringComparison.Ordinal)) + return Forbid(); + + if (string.Equals(outcome.Error, "Team member not found.", StringComparison.Ordinal)) + return NotFound(new { message = outcome.Error }); + + return BadRequest(new { message = outcome.Error }); + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index f220dfa..6baf11f 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -222,6 +222,13 @@ app.UseAuthorization(); app.MapControllers(); +using (var ownerScope = app.Services.CreateScope()) +{ + await ownerScope.ServiceProvider + .GetRequiredService() + .EnsureConfiguredOwnerAsync(CancellationToken.None); +} + //if (app.Environment.IsDevelopment()) //{ // using var scope = app.Services.CreateScope(); diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index 3e291f0..fe944ee 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -176,6 +176,12 @@ namespace Data.SeaHavenIndustries builder.Entity() .HasIndex(u => u.AccountId); + builder.Entity() + .HasIndex(u => u.IsAccountOwner) + .IsUnique() + .HasFilter("IsAccountOwner = 1") + .HasDatabaseName("IX_AspNetUsers_IsAccountOwner"); + builder.Entity() .HasIndex(c => c.NormalizedName) .IsUnique(); @@ -304,6 +310,26 @@ namespace Data.SeaHavenIndustries .IsUnique() .HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey"); }); + + builder.Entity(entity => + { + entity.HasKey(area => new { area.UserId, area.Area }); + + entity.Property(area => area.UserId) + .HasMaxLength(450); + + entity.Property(area => area.Area) + .HasMaxLength(32); + + entity.HasOne(area => area.User) + .WithMany(user => user.ServiceAreas) + .HasForeignKey(area => area.UserId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasIndex(area => new { area.UserId, area.Area }) + .IsUnique() + .HasDatabaseName("IX_UserServiceAreas_UserId_Area"); + }); } public DbSet Categories { get; set; } public DbSet Locations { get; set; } @@ -361,6 +387,7 @@ namespace Data.SeaHavenIndustries public DbSet JobTitles { get; set; } public DbSet Regions { get; set; } public DbSet UserPermissionOverrides { get; set; } + public DbSet UserServiceAreas { get; set; } public override int SaveChanges() { @@ -440,12 +467,16 @@ namespace Data.SeaHavenIndustries public string? Initials { get; set; } public string? Color { get; set; } public int? Type { get; set; } // 1 for users 0 for admin + public bool IsAccountOwner { get; set; } + public bool? PendingRegistration { get; set; } + public DateTime? PendingRegistrationCreatedDate { get; set; } /// Optional CRM account membership for server-derived media scope (SH-221). public int? AccountId { get; set; } [ForeignKey(nameof(AccountId))] public virtual Accounts? Account { get; set; } public ICollection