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/DashboardControllerTests.cs b/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs new file mode 100644 index 0000000..4ef3b58 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs @@ -0,0 +1,54 @@ +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class DashboardControllerTests +{ + private static object Prop(object source, string name) => + source.GetType().GetProperty(name)!.GetValue(source)!; + + private static DashboardController NewController(Mock service) => + new(service.Object) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + + // Guards the Stats wire object, not the service DTO. GetKpiCountsAsync and + // DashboardStatsDTO already carried these three counts; the regression this + // pins is the controller's anonymous response silently dropping them, which + // is what left the tiles with nothing to read. + [Fact] + public async Task GetStats_SerialisesKpiCountsOnWireObject() + { + var service = new Mock(); + service.Setup(s => s.GetStatsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new DashboardStatsDTO + { + ScheduledTomorrow = 3, + PendingUplifts = 5, + AvetaPending = 7 + }); + + var result = await NewController(service).GetStats( + new DashboardStatsQueryDTO(), CancellationToken.None); + + var body = result.Should().BeOfType().Subject.Value!; + ((int)Prop(body, "scheduledTomorrow")).Should().Be(3); + ((int)Prop(body, "pendingUplifts")).Should().Be(5); + ((int)Prop(body, "avetaPending")).Should().Be(7); + } +} diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index 0af8f00..2cc73ee 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -1,7 +1,12 @@ +using System.Security.Claims; using Data.SeaHavenIndustries; using FluentAssertions; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using Xunit; @@ -17,33 +22,994 @@ public class DashboardServiceTests return new ApplicationDbContext(options); } - private static DashboardService NewService(ApplicationDbContext ctx) => - new(new DashboardDataService(ctx)); + private static DashboardService NewService(ApplicationDbContext ctx) + { + var resolver = new WorkOrderAccountResolver( + new AccountDataService(ctx), + new LocationDataService(ctx)); + return new DashboardService(new DashboardDataService(ctx), resolver); + } - private static WorkOrder Wo(string? status, bool? isTemplate = false, string? assignTo = null) => - new() { Status = status, istemplate = isTemplate, AssignTo = assignTo }; + private static ClaimsPrincipal AccountUser( + int accountId, + string userId = "dispatcher-1", + string role = "Admin") + { + var claims = new[] + { + new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()), + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(ClaimTypes.Role, role) + }; + return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + } + + private static ClaimsPrincipal OrgWideUser() + { + var claims = new[] + { + new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll), + new Claim(ClaimTypes.Role, "Admin") + }; + return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + } [Fact] public async Task GetStatsAsync_CountsByStatusExcludingTemplates() { using var ctx = NewContext(); ctx.workOrders.AddRange( - Wo("Open", isTemplate: false), - Wo("Open", isTemplate: false, assignTo: "u1"), - Wo("In Progress"), - Wo("On Hold"), - Wo("Done"), - Wo("Done"), - Wo("Open", isTemplate: true), - Wo("Cancelled", isTemplate: false) + new WorkOrder { AccountId = 1, Status = "Open", istemplate = false }, + new WorkOrder { AccountId = 1, Status = "Open", istemplate = false, AssignTo = "u1" }, + new WorkOrder { AccountId = 1, Status = "In Progress" }, + new WorkOrder { AccountId = 1, Status = "On Hold" }, + new WorkOrder { AccountId = 1, Status = "Done" }, + new WorkOrder { AccountId = 1, Status = "Done" }, + new WorkOrder { AccountId = 1, Status = "Open", istemplate = true }, + new WorkOrder { AccountId = 1, Status = "Cancelled" }, + new WorkOrder { AccountId = 2, Status = "Open" }, + new WorkOrder { Status = "Open" } ); ctx.SaveChanges(); - var stats = await NewService(ctx).GetStatsAsync(CancellationToken.None); + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); stats.Total.Should().Be(7); stats.Open.Should().Be(4); stats.NotDispatched.Should().Be(1); stats.Completed.Should().Be(2); } + + [Fact] + public async Task GetStatsAsync_OrgWideUserSeesAllNonTemplateOrders() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Open" }, + new WorkOrder { AccountId = 2, Status = "Done" }, + new WorkOrder { Status = "Open", istemplate = true }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync( + OrgWideUser(), new DashboardStatsQueryDTO(), CancellationToken.None); + + stats.Total.Should().Be(2); + stats.Open.Should().Be(1); + stats.Completed.Should().Be(1); + } + + [Fact] + public async Task GetStatsAsync_ExposesKpiCounts_PendingUpliftsCountsPendingStatusOnly() + { + using var ctx = NewContext(); + var today = DashboardBusinessTime.Today(); + var tomorrow = today.AddDays(1); + + // Scheduled-tomorrow tile: one account-1 order scheduled for tomorrow. + ctx.workOrders.Add(new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = tomorrow.ToDateTime(TimeOnly.MinValue) + }); + + // Aveta-pending tile: Aveta-required order in the today/tomorrow window + // with no Aveta attachment. Its Dispatch anchors the uplift requests below. + var avetaOrder = new WorkOrder + { + AccountId = 1, + AvetaRequired = true, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue) + }; + ctx.workOrders.Add(avetaOrder); + ctx.SaveChanges(); + + var dispatch = new Dispatch { WorkOrderId = avetaOrder.Id, IsDeleted = false }; + ctx.Set().Add(dispatch); + ctx.SaveChanges(); + + // Pending-uplifts tile: only Status == "Pending" is an outstanding uplift + // awaiting an approval decision. This test pins the behaviour the code + // ships today: a "ChangesRequested" request has been sent back to the + // vendor, so it is NOT counted. Whether pendingUplifts should also include + // ChangesRequested is an open product call, not a settled review decision. + ctx.Set().AddRange( + new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "Pending", IsDeleted = false }, + new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "ChangesRequested", IsDeleted = false }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); + + stats.ScheduledTomorrow.Should().Be(1); + stats.AvetaPending.Should().Be(1); + stats.PendingUplifts.Should().Be(1); + } + + [Fact] + public async Task GetStatsAsync_MissingScopeFailsClosed() + { + using var ctx = NewContext(); + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.Role, "Dispatcher") + }, "test")); + + var act = () => NewService(ctx).GetStatsAsync( + user, new DashboardStatsQueryDTO(), CancellationToken.None); + + var exception = await act.Should().ThrowAsync(); + + exception.Which.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task GetStatsAsync_UsesDashboardMetricRulesForDateRange() + { + using var ctx = NewContext(); + var today = DashboardBusinessTime.Today(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.PM, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue), + OriginalDate = today.AddDays(-6) + }, + new WorkOrder + { + AccountId = 1, + WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.Emergency, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.InProgress, + ScheduledDate = today.AddDays(-3).ToDateTime(TimeOnly.MinValue) + }, + new WorkOrder + { + AccountId = 1, + WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.Reactive, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue), + OriginalDate = today.AddDays(-15), + CompletedDate = today.AddDays(-12).ToDateTime(TimeOnly.MinValue) + }, + new WorkOrder + { + AccountId = 1, + WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.PM, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Incomplete, + ScheduledDate = today.AddDays(4).ToDateTime(TimeOnly.MinValue) + }); + ctx.SaveChanges(); + + var query = new DashboardStatsQueryDTO + { + DateFrom = today.AddDays(-10), + DateTo = today.AddDays(10) + }; + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1), query, CancellationToken.None); + + stats.Total.Should().Be(4); + stats.Breakdown.Overdue.Should().Be(1); + stats.Breakdown.Other.Should().Be(0); + stats.Breakdown.PM.Should().Be(2); + stats.Breakdown.Emergency.Should().Be(0); + stats.Breakdown.Reactive.Should().Be(1); + (stats.Breakdown.PM + stats.Breakdown.Emergency + stats.Breakdown.Reactive + + stats.Breakdown.Overdue + stats.Breakdown.Other).Should().Be(stats.Total); + stats.DueCount.Should().Be(3); + stats.CompletedDueCount.Should().Be(1); + stats.CompletionRate.Should().Be(33.33m); + stats.AverageResolutionDays.Should().Be(3m); + } + + [Fact] + public async Task GetStatsAsync_DispatcherOnlySeesAssignedOrders() + { + 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, role: "Dispatcher"), new DashboardStatsQueryDTO(), CancellationToken.None); + + stats.Total.Should().Be(1); + stats.Open.Should().Be(1); + } + + [Fact] + public async Task GetWorkloadAsync_UsesRoleScopeAndExcludesTerminalOrdersFromOpenCount() + { + 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", LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed }, + new WorkOrder { AccountId = 1, AssignTo = "dispatcher-2", Status = "Open" }); + ctx.SaveChanges(); + + var result = await NewService(ctx).GetWorkloadAsync( + AccountUser(1, "dispatcher-1", "Dispatcher"), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + 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( + AccountUser(1), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + adminResult.TotalDispatchers.Should().Be(2); + adminResult.Items.Select(item => item.DispatcherName) + .Should().ContainInOrder("Ada One", "Ben Two"); + + var performance = await NewService(ctx).GetPerformanceAsync( + AccountUser(1), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + performance.TotalDispatchers.Should().Be(2); + performance.Items[0].DispatcherName.Should().Be("Ada One"); + } + + [Fact] + public async Task GetPerformanceAsync_UsesDueOnlyRateAndOriginalDateResolution() + { + 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", + Color = "#123456" + }); + ctx.UserRoles.Add(new IdentityUserRole + { + UserId = "dispatcher-1", + RoleId = "dispatcher-role" + }); + var today = DashboardBusinessTime.Today(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue), + OriginalDate = today.AddDays(-5), + CompletedDate = today.AddDays(-2).ToDateTime(TimeOnly.MinValue) + }, + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.AddDays(3).ToDateTime(TimeOnly.MinValue) + }); + ctx.SaveChanges(); + + var result = await NewService(ctx).GetPerformanceAsync( + AccountUser(1), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + result.Items.Should().ContainSingle(); + result.Items[0].CompletionRate.Should().Be(100m); + result.Items[0].AverageResolutionDays.Should().Be(3m); + result.Items[0].Color.Should().Be("#123456"); + } + + [Fact] + public async Task GetRegionsAsync_UsesCanonicalBucketsAndUnmappedOther() + { + await using var ctx = NewContext(); + ctx.Locations.AddRange( + new Locations { Id = 1, State = "NY" }, + new Locations { Id = 2, State = "ca" }, + new Locations { Id = 3, State = "XX" }, + new Locations { Id = 4, State = "indiana" }, + new Locations { Id = 5, State = "NEW YORK" }, + new Locations { Id = 6, State = "California" }); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, LocationId = 1 }, + new WorkOrder { AccountId = 1, LocationId = 2 }, + new WorkOrder { AccountId = 1, LocationId = 3 }, + new WorkOrder { AccountId = 1, LocationId = 4 }, + new WorkOrder { AccountId = 1, LocationId = 5 }, + new WorkOrder { AccountId = 1, LocationId = 6 }, + new WorkOrder { AccountId = 1, LocationId = null }); + await ctx.SaveChangesAsync(); + + var result = await NewService(ctx).GetRegionsAsync( + AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); + + result.Items.Select(row => row.Region).Should().Equal( + "East", "Central", "West", "California", "Unmapped/Other"); + // NY + "NEW YORK" -> East; "indiana" -> Central; "ca" + "California" -> California; + // "XX" + unassigned location -> Unmapped/Other. Full-name storage forms must not + // fall through to Unmapped/Other (SH-348 review). + result.Items.Select(row => row.WorkOrderCount).Should().Equal(2, 1, 0, 2, 2); + } + + [Fact] + public void Resolve_MapsEveryUsStateAndItsFullNameToACanonicalBucket() + { + foreach (var code in UsStateCodes.All) + { + var byCode = DashboardRegions.Resolve(code); + byCode.Should().NotBe( + "Unmapped/Other", + $"state code {code} must belong to a canonical region"); + + var fullName = UsStateCodes.ExpandStorageValues(new[] { code }) + .First(value => value != code); + DashboardRegions.Resolve(fullName).Should().Be( + byCode, + $"the full-name storage form of {code} must resolve to the same region as the code"); + } + } + + [Fact] + public async Task GetVendorInsightsAsync_IsCompanyWideAndUsesVendorMetrics() + { + await using var ctx = NewContext(); + ctx.VendorCompanies.Add(new VendorCompany { Id = 10, Name = "Acme Services", IsDeleted = false }); + ctx.Vendors.Add(new Vendor + { + Id = 20, + CompanyId = 10, + IsActive = true, + CompanyName = "Acme Services" + }); + ctx.Dispatches.Add(new Dispatch { Id = 30, VendorId = 20 }); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + PrimaryDispatchId = 30, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = DateTime.UtcNow.Date.AddDays(-3), + CompletedDate = DateTime.UtcNow.Date.AddDays(-1), + RescheduleCount = 1 + }, + new WorkOrder + { + AccountId = 1, + PrimaryDispatchId = 30, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = DateTime.UtcNow.Date.AddDays(-2), + CompletedDate = DateTime.UtcNow.Date.AddDays(-1) + }); + await ctx.SaveChangesAsync(); + + var result = await NewService(ctx).GetVendorInsightsAsync( + AccountUser(1), CancellationToken.None); + + result.TotalVendors.Should().Be(1); + result.Items.Should().ContainSingle(); + result.Items[0].TotalJobs.Should().Be(2); + result.Items[0].CompletionRate.Should().Be(100); + 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() + { + using var ctx = NewContext(); + // ScheduledDate is a stored calendar value (board writes persist `.Date`), so a work + // order buckets on its own calendar day regardless of the time-of-day component. The + // UTC hours below were previously shifted a full day by an America/New_York conversion + // (03:00 UTC -> prior evening), so they double as a regression guard: each row must now + // stay on the calendar date it was scheduled for. + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 15, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled, + ScheduledDate = new DateTime(2026, 1, 14, 15, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = new DateTime(2026, 1, 15, 17, 0, 0, DateTimeKind.Utc), + CompletedDate = new DateTime(2026, 1, 16, 15, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 16, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 17, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 13, 20, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = new DateOnly(2026, 1, 14), + DateTo = new DateOnly(2026, 1, 16) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + trend.Granularity.Should().Be("day"); + trend.Buckets.Select(bucket => bucket.Date).Should().Equal( + new DateOnly(2026, 1, 14), new DateOnly(2026, 1, 15), new DateOnly(2026, 1, 16)); + trend.Buckets.Select(bucket => bucket.Label).Should().Equal( + "2026-01-14", "2026-01-15", "2026-01-16"); + + // 01-14: only the canceled row scheduled that calendar day. + trend.Buckets[0].Total.Should().Be(1); + trend.Buckets[0].Open.Should().Be(0); + trend.Buckets[0].Canceled.Should().Be(1); + trend.Buckets[0].Completed.Should().Be(0); + trend.Buckets[0].Overdue.Should().Be(0); + + // 01-15: the 03:00 UTC scheduled row (no longer shifted to 01-14) plus the completed row. + trend.Buckets[1].Total.Should().Be(2); + trend.Buckets[1].Open.Should().Be(1); + trend.Buckets[1].Completed.Should().Be(1); + trend.Buckets[1].Canceled.Should().Be(0); + trend.Buckets[1].Overdue.Should().Be(1); + + // 01-16: the 03:00 UTC scheduled row that stays on its own day. + trend.Buckets[2].Total.Should().Be(1); + trend.Buckets[2].Open.Should().Be(1); + trend.Buckets[2].Overdue.Should().Be(1); + + trend.Buckets.Should().OnlyContain(bucket => !bucket.IsCurrent); + } + + [Fact] + public async Task GetTrendAsync_MidnightScheduledDateStaysInTodayBucketAndIsNotOverdue() + { + using var ctx = NewContext(); + var today = DashboardBusinessTime.Today(); + // A board-scheduled work order for today is stored as midnight (`.Date`). It must land + // in the Today bucket and count as open, not roll back to yesterday and read as overdue. + ctx.workOrders.Add(new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = today.AddDays(-1), + DateTo = today.AddDays(1) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + var todayBucket = trend.Buckets.Single(bucket => bucket.Date == today); + todayBucket.Total.Should().Be(1); + todayBucket.Open.Should().Be(1); + todayBucket.Overdue.Should().Be(0); + + var yesterdayBucket = trend.Buckets.Single(bucket => bucket.Date == today.AddDays(-1)); + yesterdayBucket.Total.Should().Be(0); + } + + [Fact] + public async Task GetTrendAsync_ThreeMonthRangeUsesWeeklyMondayBuckets() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 17, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 16, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = new DateTime(2026, 9, 16, 12, 0, 0, DateTimeKind.Utc), + CompletedDate = new DateTime(2026, 9, 16, 18, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + Range = "3m", + DateFrom = new DateOnly(2026, 6, 17), + DateTo = new DateOnly(2026, 9, 16) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + trend.Granularity.Should().Be("week"); + trend.Buckets.Should().HaveCount(14); + trend.Buckets.Select(bucket => bucket.Date.DayOfWeek) + .Should().OnlyContain(day => day == DayOfWeek.Monday); + trend.Buckets[0].Date.Should().Be(new DateOnly(2026, 6, 15)); + trend.Buckets[0].Total.Should().Be(2); + trend.Buckets[0].Open.Should().Be(2); + trend.Buckets[^1].Date.Should().Be(new DateOnly(2026, 9, 14)); + trend.Buckets[^1].Total.Should().Be(1); + trend.Buckets[^1].Completed.Should().Be(1); + } + + [Fact] + public async Task GetTrendAsync_YearFilterUsesMonthlyBuckets() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = new DateTime(2026, 3, 20, 4, 0, 0, DateTimeKind.Utc), + CompletedDate = new DateTime(2026, 3, 21, 4, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2025, 12, 31, 20, 0, 0, DateTimeKind.Utc) + }, + // 2027-01-01 by calendar: outside the 2026 window. Previously an + // America/New_York conversion pulled it back to 2026-12-31 and into December. + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2027, 1, 1, 3, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), new DashboardTrendQueryDTO { Year = 2026 }, CancellationToken.None); + + trend.Granularity.Should().Be("month"); + trend.Buckets.Should().HaveCount(12); + trend.Buckets[0].Label.Should().Be("2026-01-01"); + trend.Buckets[0].Total.Should().Be(1); + trend.Buckets[0].Open.Should().Be(1); + trend.Buckets[0].Overdue.Should().Be(1); + trend.Buckets[2].Label.Should().Be("2026-03-01"); + trend.Buckets[2].Total.Should().Be(1); + trend.Buckets[2].Completed.Should().Be(1); + // December stays empty: the 2027-01-01 row is no longer pulled inside the window. + trend.Buckets[11].Label.Should().Be("2026-12-01"); + trend.Buckets[11].Total.Should().Be(0); + trend.Buckets[11].Open.Should().Be(0); + trend.Today.Should().Be(DashboardBusinessTime.Today()); + } + + [Fact] + public async Task GetTrendAsync_MarksTodayBucketAsCurrent() + { + using var ctx = NewContext(); + var today = DashboardBusinessTime.Today(); + ctx.workOrders.Add(new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.AddDays(-2).ToDateTime(new TimeOnly(12, 0)) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = today.AddDays(-2), + DateTo = today.AddDays(2) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + trend.Today.Should().Be(today); + trend.Buckets.Should().HaveCount(5); + trend.Buckets.Count(bucket => bucket.IsCurrent).Should().Be(1); + trend.Buckets.Single(bucket => bucket.IsCurrent).Date.Should().Be(today); + trend.Buckets[0].Total.Should().Be(1); + trend.Buckets.Single(bucket => bucket.Date == today).Total.Should().Be(0); + } + + [Fact] + public async Task GetTrendAsync_DispatcherRoleAndTenantScopeMatchStats() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-2", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 2, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = new DateOnly(2026, 2, 2), + DateTo = new DateOnly(2026, 2, 3) + }; + + var dispatcherTrend = await NewService(ctx).GetTrendAsync( + AccountUser(1, "dispatcher-1", "Dispatcher"), query, CancellationToken.None); + + dispatcherTrend.Buckets.Should().HaveCount(2); + dispatcherTrend.Buckets[0].Total.Should().Be(1); + dispatcherTrend.Buckets[1].Total.Should().Be(0); + + var adminTrend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + adminTrend.Buckets[0].Total.Should().Be(2); + + var otherAccountTrend = await NewService(ctx).GetTrendAsync( + AccountUser(2), query, CancellationToken.None); + + otherAccountTrend.Buckets[0].Total.Should().Be(1); + } } diff --git a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs index 47e53dd..888f092 100644 --- a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs @@ -26,7 +26,7 @@ public class LocationControllerTests public async Task GetLocationList_ReturnsPaginationEnvelopeWithServiceData() { var service = new Mock(); - service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny())) + service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new PagedResult { Items = new List { new() { Id = 1, Name = "Site" } }, @@ -49,7 +49,7 @@ public class LocationControllerTests public async Task GetLocationList_PassesStatesFilterToService() { var service = new Mock(); - service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny())) + service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new PagedResult { Items = new List(), @@ -58,7 +58,7 @@ public class LocationControllerTests PageSize = 10 }); - var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", CancellationToken.None); + var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", cancellationToken: CancellationToken.None); result.Should().BeOfType(); service.VerifyAll(); @@ -68,13 +68,13 @@ public class LocationControllerTests public async Task GetLocationList_WhenStatesInvalid_ReturnsExistingValidationErrorShape() { var service = new Mock(); - service.Setup(s => s.GetLocationListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + service.Setup(s => s.GetLocationListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new ValidationException(new[] { new ValidationFailure("states", "states must be valid US postal abbreviations: ZZ.") })); - var result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", CancellationToken.None); + var result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", cancellationToken: CancellationToken.None); var bad = result.Should().BeOfType().Subject; var response = bad.Value.Should().BeOfType().Subject; @@ -200,4 +200,90 @@ public class LocationControllerTests var notFound = result.Should().BeOfType().Subject; notFound.Value.Should().BeOfType().Subject.Message.Should().Be("Location not found"); } + + [Fact] + public async Task GetLocationList_ForwardsSortParamsToService() + { + var service = new Mock(); + service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny(), "city", "desc")) + .ReturnsAsync(new PagedResult { Items = new List(), TotalCount = 0, Page = 1, PageSize = 10 }); + + await NewController(service).GetLocationList(sortBy: "city", sortDirection: "desc", cancellationToken: CancellationToken.None); + + service.Verify(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny(), "city", "desc"), Times.Once); + } + + [Fact] + public async Task GetLocationList_WhenSortInvalid_ReturnsExistingValidationErrorShape() + { + var service = new Mock(); + service.Setup(s => s.GetLocationListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new ValidationException(new[] + { + new ValidationFailure("sortBy", "sortBy must be one of: code, client, address, city, state, poc.") + })); + + var result = await NewController(service).GetLocationList(sortBy: "nope", cancellationToken: CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Validation Error"); + response.Message.Should().Contain("sortBy"); + } + + [Fact] + public async Task GetLocationList_ProjectsClientAccountNameAndSiteFields() + { + var service = new Mock(); + service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny(), null, null)) + .ReturnsAsync(new PagedResult + { + Items = new List + { + new() + { + Id = 1, + Name = "STL8", + Address1 = "9 River Rd", + City = "St. Louis", + State = "MO", + Zip = "63101", + PhoneNumber = "555-0100", + Email = "poc@example.com", + AccountId = 3, + AccountName = "Acme Co", + Contacts = new List + { + new() { Id = 10, Name = "Cara Lane", Phone = "555-0100" }, + new() { Id = 11, Name = "Alan Ford", Phone = "555-0101" } + } + } + }, + TotalCount = 1, + Page = 1, + PageSize = 10 + }); + + var result = await NewController(service).GetLocationList(cancellationToken: CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + using var json = System.Text.Json.JsonSerializer.SerializeToDocument(ok.Value); + var row = json.RootElement.GetProperty("Data").EnumerateArray().Single(); + + row.GetProperty("Name").GetString().Should().Be("STL8", "the site code is the legacy Name field"); + row.GetProperty("Address").GetString().Should().Be("9 River Rd"); + row.GetProperty("City").GetString().Should().Be("St. Louis"); + row.GetProperty("State").GetString().Should().Be("MO"); + row.GetProperty("ZipCode").GetString().Should().Be("63101"); + row.GetProperty("Phone").GetString().Should().Be("555-0100"); + row.GetProperty("ContactEmail").GetString().Should().Be("poc@example.com"); + row.GetProperty("Contact").GetString().Should().Be("Cara Lane", "the first ordered contact is the legacy Contact field"); + row.GetProperty("AccountId").GetInt32().Should().Be(3); + row.GetProperty("ClientName").GetString().Should().Be("Acme Co"); + row.GetProperty("AccountName").GetString().Should().Be("Acme Co"); + var contacts = row.GetProperty("Contacts"); + contacts.GetArrayLength().Should().Be(2); + contacts[0].GetProperty("Name").GetString().Should().Be("Cara Lane"); + contacts[1].GetProperty("Name").GetString().Should().Be("Alan Ford"); + } } diff --git a/Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs index 0dafeaf..595f78e 100644 --- a/Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs @@ -86,4 +86,129 @@ public class LocationDataServiceTests totalCount.Should().Be(2); items.Select(l => l.State).Should().BeEquivalentTo("indiana", "MO"); } + + private static async Task SeedRegistryAsync(ApplicationDbContext ctx) + { + ctx.Accounts.AddRange( + new Accounts { Id = 1, Name = "Acme Co", IsDeleted = false }, + new Accounts { Id = 2, Name = "Borealis LLC", IsDeleted = false }); + ctx.Locations.AddRange( + new Locations { Id = 10, Name = "STL8", Address1 = "9 River Rd", City = "St. Louis", State = "MO", AccountId = 1 }, + new Locations { Id = 11, Name = "DFW8", Address1 = "2 Prairie Ave", City = "Dallas", State = "TX", AccountId = 2 }, + new Locations { Id = 12, Name = "IND9", Address1 = "5 Circle Blvd", City = "Indianapolis", State = "IN", AccountId = 1 }, + new Locations { Id = 13, Name = "MIA2", Address1 = "1 Bay Dr", City = "Miami", State = "FL", AccountId = 2 }); + ctx.Contacts.AddRange( + new Contacts { Id = 100, LocationId = 10, SiteContactOrder = 0, FirstName = "Cara Lane", PhoneNumber = "555-0001" }, + new Contacts { Id = 101, LocationId = 10, SiteContactOrder = 1, FirstName = "Alan Ford", PhoneNumber = "555-0002" }, + new Contacts { Id = 102, LocationId = 11, SiteContactOrder = 1, FirstName = "Zoe Park", PhoneNumber = "555-0003" }, + new Contacts { Id = 103, LocationId = 11, SiteContactOrder = 0, FirstName = "Removed Poc", PhoneNumber = "555-0004", IsDeleted = true }, + new Contacts { Id = 104, LocationId = 12, SiteContactOrder = 0, FirstName = "Bob Quinn", PhoneNumber = "555-0005" }); + await ctx.SaveChangesAsync(); + } + + [Theory] + [InlineData("STL", new[] { 10 }, "site code")] + [InlineData("Borealis", new[] { 11, 13 }, "client name")] + [InlineData("Prairie", new[] { 11 }, "street address")] + [InlineData("Miami", new[] { 13 }, "city")] + public async Task GetListPagedAsync_SearchMatchesCodeClientAddressAndCity(string term, int[] expectedIds, string becauseField) + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, totalCount) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, term, null, CancellationToken.None); + + totalCount.Should().Be(expectedIds.Length, $"search must match the {becauseField}"); + items.Select(l => l.Id).Should().BeEquivalentTo(expectedIds); + } + + [Theory] + [InlineData("code", 11, 12, 13, 10)] + [InlineData("client", 10, 12, 11, 13)] + [InlineData("address", 13, 11, 12, 10)] + [InlineData("city", 11, 12, 13, 10)] + [InlineData("state", 13, 12, 10, 11)] + [InlineData("poc", 13, 12, 10, 11)] + public async Task GetListPagedAsync_SortByAllowlistedColumn_OrdersAscending_WithIdTiebreak( + string sortBy, params int[] expectedIds) + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, _) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None, sortBy, "asc"); + + items.Select(l => l.Id).Should().ContainInOrder(expectedIds); + } + + [Fact] + public async Task GetListPagedAsync_SortByPoc_IgnoresDeletedContacts_AndSortsByFirstActiveContact() + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, _) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None, "poc", "asc"); + + // DFW8's order-0 row is soft deleted, so its POC is "Zoe Park" (order 1) and it must + // sort last; including the deleted row ("Removed Poc") would instead place it second. + items.Select(l => l.Id).Should().ContainInOrder(13, 12, 10, 11); + } + + [Fact] + public async Task GetListPagedAsync_SortByClient_Desc_ReversesAccountOrder() + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, _) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None, "client", "desc"); + + items.Select(l => l.Id).Should().ContainInOrder(new[] { 11, 13, 10, 12 }); + } + + [Fact] + public async Task GetListPagedAsync_UnknownSortColumn_FallsBackToLegacyCodeOrder() + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, _) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None, "not-a-column", "sideways"); + + items.Select(l => l.Name).Should().ContainInOrder("DFW8", "IND9", "MIA2", "STL8"); + } + + [Fact] + public async Task GetListPagedAsync_EqualSortValues_BreakTiesById_AcrossPages() + { + await using var ctx = NewContext(); + ctx.Locations.AddRange( + new Locations { Id = 20, Name = "HOU1", City = "Dallas" }, + new Locations { Id = 21, Name = "AUS3", City = "Dallas" }, + new Locations { Id = 22, Name = "ELP4", City = "Dallas" }); + await ctx.SaveChangesAsync(); + + var service = new LocationDataService(ctx); + + var (page1, totalCount) = await service.GetListPagedAsync(1, 2, null, null, CancellationToken.None, "city", "asc"); + var (page2, _) = await service.GetListPagedAsync(2, 2, null, null, CancellationToken.None, "city", "asc"); + + totalCount.Should().Be(3); + page1.Select(l => l.Id).Should().ContainInOrder(20, 21); + page2.Select(l => l.Id).Should().ContainInOrder(22); + } + + [Fact] + public async Task GetListPagedAsync_ListLoadIncludesAccountNameForProjection() + { + await using var ctx = NewContext(); + await SeedRegistryAsync(ctx); + + var (items, _) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None); + + items.Single(l => l.Id == 10).Account!.Name.Should().Be("Acme Co"); + } } diff --git a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs index c8986b6..2c986c8 100644 --- a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs @@ -131,8 +131,8 @@ public class LocationServiceTests { var data = new Mock(); IReadOnlyCollection? captured = default; - data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny?>(), It.IsAny())) - .Callback?, CancellationToken>((_, _, _, states, _) => captured = states) + data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback?, CancellationToken, string?, string?>((_, _, _, states, _, _, _) => captured = states) .ReturnsAsync((new List(), 0)); await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, " tx , Mo ,,TX, ", CancellationToken.None); @@ -149,8 +149,8 @@ public class LocationServiceTests { var data = new Mock(); IReadOnlyCollection? captured = default; - data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) - .Callback?, CancellationToken>((_, _, _, stateFilter, _) => captured = stateFilter) + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback?, CancellationToken, string?, string?>((_, _, _, stateFilter, _, _, _) => captured = stateFilter) .ReturnsAsync((new List(), 0)); var page = await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, "search", states, CancellationToken.None); @@ -173,14 +173,14 @@ public class LocationServiceTests && e.ErrorMessage.Contains("ZZ") && e.ErrorMessage.Contains("QQ") && !e.ErrorMessage.Contains("TX")); - data.Verify(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny()), Times.Never); + data.Verify(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task GetLocationListPagedAsync_AcceptsAllFiftyStateCodes() { var data = new Mock(); - data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((new List(), 0)); var allStates = string.Join(",", SeaHaven.Services.Helpers.UsStateCodes.All); @@ -533,4 +533,97 @@ public class LocationServiceTests again.Should().BeFalse(); ctx.Locations.Should().BeEmpty(); } + + [Fact] + public async Task GetLocationListPagedAsync_NormalizesAndForwardsSortToDataService() + { + var data = new Mock(); + string? capturedSort = "sentinel"; + string? capturedDirection = "sentinel"; + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback?, CancellationToken, string?, string?>((_, _, _, _, _, sortBy, sortDirection) => + { + capturedSort = sortBy; + capturedDirection = sortDirection; + }) + .ReturnsAsync((new List(), 0)); + + await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: " POC ", sortDirection: " DESC "); + + capturedSort.Should().Be("poc"); + capturedDirection.Should().Be("desc"); + } + + [Theory] + [InlineData(null, null)] + [InlineData("", " ")] + public async Task GetLocationListPagedAsync_BlankOrDefaultSort_PassesNormalizedDefaults( + string? sortBy, + string? sortDirection) + { + var data = new Mock(); + string? capturedSort = "sentinel"; + string? capturedDirection = "sentinel"; + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback?, CancellationToken, string?, string?>((_, _, _, _, _, s, d) => + { + capturedSort = s; + capturedDirection = d; + }) + .ReturnsAsync((new List(), 0)); + + await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy, sortDirection: sortDirection); + + capturedSort.Should().BeNull("a blank sort column must keep the legacy ordering"); + capturedDirection.Should().Be("asc"); + } + + [Theory] + [InlineData("rating")] + [InlineData("password; drop table locations")] + public async Task GetLocationListPagedAsync_InvalidSortBy_ThrowsValidationAndNeverQueries(string sortBy) + { + var data = new Mock(); + + var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().ContainSingle(e => + e.PropertyName == "sortBy" + && e.ErrorMessage.Contains("code") + && e.ErrorMessage.Contains("poc")); + data.Verify(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData("ascending")] + [InlineData("DESC; drop table locations")] + public async Task GetLocationListPagedAsync_InvalidSortDirection_ThrowsValidationAndNeverQueries(string sortDirection) + { + var data = new Mock(); + + var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: "city", sortDirection: sortDirection); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().ContainSingle(e => + e.PropertyName == "sortDirection" + && e.ErrorMessage.Contains("asc") + && e.ErrorMessage.Contains("desc")); + data.Verify(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetLocationListPagedAsync_MapsClientAccountNameIntoRows() + { + using var ctx = NewContext(); + SeedAccount(ctx, 9, "Acme Co"); + SeedLocation(ctx, "Alpha Site", "Austin").AccountId = 9; + await ctx.SaveChangesAsync(); + + var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None); + + var row = page.Items.Should().ContainSingle().Subject; + row.AccountId.Should().Be(9); + row.AccountName.Should().Be("Acme Co"); + } } diff --git a/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs b/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs index 5b9cf9d..f4aef07 100644 --- a/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs @@ -430,7 +430,7 @@ public class LocationSiteContactsTests public async Task List_LoadsPageContactsInSingleBatchedRead() { var data = new Mock(); - data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((new List { new() { Id = 1, Name = "One" }, diff --git a/Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs b/Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs new file mode 100644 index 0000000..c1d6cf0 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs @@ -0,0 +1,185 @@ +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class ServicesRegistryControllerTests +{ + private static ServicesRegistryController NewController(Mock service, params string[] roles) + { + var controller = new ServicesRegistryController(service.Object) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + roles.Select(r => new Claim(ClaimTypes.Role, r)), "Test")) + } + } + }; + return controller; + } + + private static ServicesRegistryValidationErrorDto? ErrorValue(IActionResult result) => + (result as ObjectResult)?.Value as ServicesRegistryValidationErrorDto; + + [Fact] + public async Task GetAll_ReturnsOkWithServices() + { + var service = new Mock(); + service.Setup(s => s.GetAllAsync(true, WorkOrderType.PM, It.IsAny())) + .ReturnsAsync(new List { new() { Id = 1, Name = "Leak", IsActive = true } }); + + var result = await NewController(service, "Admin").GetAll(true, WorkOrderType.PM, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + (ok.Value as IEnumerable).Should().ContainSingle(s => s.Name == "Leak"); + } + + [Fact] + public async Task GetAll_WhenValidationCode_ReturnsBadRequestWithStableCodeAndMessage() + { + var service = new Mock(); + service.Setup(s => s.GetAllAsync(null, WorkOrderType.Project, It.IsAny())) + .Throws(new ServicesRegistryValidationException( + "WorkOrderTypeInvalid", "Supported work order types may only include PM, Reactive, or Emergency.")); + + var result = await NewController(service, "Admin").GetAll(null, WorkOrderType.Project, CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + ErrorValue(bad)!.Code.Should().Be("WorkOrderTypeInvalid"); + ErrorValue(bad)!.Message.Should().Be("Supported work order types may only include PM, Reactive, or Emergency."); + } + + [Fact] + public async Task GetById_WhenMissing_ReturnsNotFoundWithCode() + { + var service = new Mock(); + service.Setup(s => s.GetByIdAsync(9, It.IsAny())) + .ReturnsAsync((ServiceDto?)null); + + var result = await NewController(service, "Admin").GetById(9, CancellationToken.None); + + var notFound = result.Should().BeOfType().Subject; + ErrorValue(notFound)!.Code.Should().Be("NotFound"); + } + + [Fact] + public async Task Create_PassesCallerAndReturnsCreated() + { + var service = new Mock(); + service.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ServiceDto { Id = 42, Name = "Leak", IsActive = true }); + + var result = await NewController(service, "Scheduler").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None); + + var created = result.Should().BeOfType().Subject; + created.StatusCode.Should().Be(StatusCodes.Status201Created); + (created.Value as ServiceDto)!.Id.Should().Be(42); + service.Verify(s => s.CreateAsync( + It.Is(p => p.IsInRole("Scheduler")), + It.Is(i => i.Name == "Leak"), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Create_WhenForbidden_Returns403WithStableCode() + { + var service = new Mock(); + service.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(new ServicesRegistryValidationException("Forbidden", "Scheduler or Admin role is required.")); + + var result = await NewController(service, "User").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None); + + var forbidden = result.Should().BeOfType().Subject; + forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + ErrorValue(forbidden)!.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task Create_WhenDuplicateName_Returns400WithStableCode() + { + var service = new Mock(); + service.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists.")); + + var result = await NewController(service, "Admin").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + ErrorValue(bad)!.Code.Should().Be("DuplicateName"); + } + + [Fact] + public async Task Update_ReturnsOkWithUpdatedService() + { + var service = new Mock(); + service.Setup(s => s.UpdateAsync(It.IsAny(), 7, It.IsAny(), It.IsAny())) + .ReturnsAsync(new ServiceDto { Id = 7, Name = "Renamed" }); + + var result = await NewController(service, "Admin").Update(7, new ServiceInput { Name = "Renamed" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + (ok.Value as ServiceDto)!.Name.Should().Be("Renamed"); + } + + [Fact] + public async Task Update_WhenNotFound_Returns404WithCode() + { + var service = new Mock(); + service.Setup(s => s.UpdateAsync(It.IsAny(), 404, It.IsAny(), It.IsAny())) + .Throws(new ServicesRegistryValidationException("NotFound", "Service not found.")); + + var result = await NewController(service, "Admin").Update(404, new ServiceInput(), CancellationToken.None); + + var notFound = result.Should().BeOfType().Subject; + ErrorValue(notFound)!.Code.Should().Be("NotFound"); + } + + [Fact] + public async Task Deactivate_ReturnsOk() + { + var service = new Mock(); + + var result = await NewController(service, "Admin").Deactivate(3, CancellationToken.None); + + result.Should().BeOfType(); + service.Verify(s => s.DeactivateAsync( + It.Is(p => p.IsInRole("Admin")), 3, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Deactivate_WhenForbidden_Returns403WithStableCode() + { + var service = new Mock(); + service.Setup(s => s.DeactivateAsync(It.IsAny(), 3, It.IsAny())) + .Throws(new ServicesRegistryValidationException("Forbidden", "Only administrators can deactivate services.")); + + var result = await NewController(service, "Scheduler").Deactivate(3, CancellationToken.None); + + var forbidden = result.Should().BeOfType().Subject; + forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + ErrorValue(forbidden)!.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task Deactivate_WhenNotFound_Returns404WithCode() + { + var service = new Mock(); + service.Setup(s => s.DeactivateAsync(It.IsAny(), 404, It.IsAny())) + .Throws(new ServicesRegistryValidationException("NotFound", "Service not found.")); + + var result = await NewController(service, "Admin").Deactivate(404, CancellationToken.None); + + result.Should().BeOfType(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs b/Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs new file mode 100644 index 0000000..9eba754 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs @@ -0,0 +1,396 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +// Global Services registry behavior. The registry has no tenant scope +// (no AccountId), authorization is enforced at the service boundary, and the +// only allowed supported work-order types are PM, Reactive, and Emergency. +public class ServicesRegistryServiceTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static ServicesRegistryService NewService(ApplicationDbContext ctx) => + new(new ServicesRegistryDataService(ctx)); + + private static ClaimsPrincipal Principal(params string[] roles) => + new(new ClaimsIdentity(roles.Select(r => new Claim(ClaimTypes.Role, r)), "Test")); + + private static ClaimsPrincipal Admin() => Principal("Admin"); + private static ClaimsPrincipal Scheduler() => Principal("Scheduler"); + + private static ServiceInput Input( + string name = "Leak", + string? trade = "Plumbing & Water Systems", + string? iconKey = "plumbing-water-systems", + bool requiresDoc = false, + int? templateId = null, + List? types = null) => new() + { + Name = name, + Trade = trade, + IconKey = iconKey, + RequiresCompletionDocument = requiresDoc, + CompletionDocTemplateId = templateId, + SupportedWorkOrderTypes = types ?? new List + { + WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency + } + }; + + private static CompletionDocTemplate Template(string name, bool active = true, bool deleted = false) => + new() { Name = name, ServiceKey = "test", TemplateUrl = "https://example.com/t.pdf", IsActive = active, IsDeleted = deleted }; + + [Fact] + public async Task Create_PersistsActiveServiceWithNormalizedFieldsAndSupportedTypes() + { + using var ctx = NewContext(); + + var dto = await NewService(ctx).CreateAsync(Scheduler(), Input(" Leak Detection "), CancellationToken.None); + + dto.Id.Should().BeGreaterThan(0); + dto.Name.Should().Be("Leak Detection"); + dto.Trade.Should().Be("Plumbing & Water Systems"); + dto.IconKey.Should().Be("plumbing-water-systems"); + dto.IsActive.Should().BeTrue("new services are always active"); + dto.CompletionDocTemplate.Should().BeNull(); + dto.SupportedWorkOrderTypes.Should().BeEquivalentTo(new[] { WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency }); + + var row = ctx.Services.Include(s => s.SupportedWorkOrderTypes).Single(); + row.NormalizedName.Should().Be("leak detection"); + } + + [Fact] + public async Task Create_WhenTypeCategoriesOmitted_DefaultsToSupportedWorkOrderTypes() + { + using var ctx = NewContext(); + + var input = Input(types: null); + input.SupportedWorkOrderTypes = null; + + var dto = await NewService(ctx).CreateAsync(Admin(), input, CancellationToken.None); + + dto.SupportedWorkOrderTypes.Should().BeEquivalentTo(new[] + { + WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency + }); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Create_WhenNameBlank_ThrowsNameRequired(string? name) + { + using var ctx = NewContext(); + var act = () => NewService(ctx).CreateAsync(Admin(), Input(name: name!), CancellationToken.None); + var ex = (await act.Should().ThrowAsync()).Subject.Single(); + ex.Code.Should().Be("NameRequired"); + } + + [Fact] + public async Task Create_WhenTradeNotCanonical_ThrowsTradeInvalid() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).CreateAsync(Admin(), Input(trade: "Roofing"), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("TradeInvalid"); + } + + [Theory] + [InlineData(" hvac ")] + [InlineData("HVAC")] + [InlineData("hvac")] + public async Task Create_AcceptsCanonicalTradeInAnyCasingOrPadding(string trade) + { + using var ctx = NewContext(); + var dto = await NewService(ctx).CreateAsync(Admin(), Input(name: "Cooling", trade: trade, iconKey: "hvac"), CancellationToken.None); + dto.Trade.Should().Be("HVAC"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Create_WhenIconKeyBlank_ThrowsIconKeyInvalid(string? iconKey) + { + using var ctx = NewContext(); + var act = () => NewService(ctx).CreateAsync(Admin(), Input(iconKey: iconKey!), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("IconKeyInvalid"); + } + + [Fact] + public async Task Create_WhenNormalizedNameExists_ThrowsDuplicateName_RegardlessOfCasing() + { + using var ctx = NewContext(); + var service = NewService(ctx); + await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + + var act = () => service.CreateAsync(Admin(), Input(" LEAK "), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("DuplicateName"); + } + + [Fact] + public async Task Registry_IsGlobal_SingleScopeWithoutAccountOrTenantFilter() + { + using var ctx = NewContext(); + var service = NewService(ctx); + // Distinct actors with different role sets still share one global registry. + var first = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + var act = () => service.CreateAsync(Scheduler(), Input("Leak"), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("DuplicateName"); + + var all = await service.GetAllAsync(null, null, CancellationToken.None); + all.Should().ContainSingle(s => s.Id == first.Id); + typeof(Service).GetProperty("AccountId").Should().BeNull("tenant scope is global, matching DropdownOptions"); + } + + [Fact] + public async Task Create_WhenCallerLacksSchedulerAndAdminRole_ThrowsForbidden() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).CreateAsync(Principal("User"), Input(), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task Create_WhenCallerUnauthenticated_ThrowsForbidden() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).CreateAsync(Principal(), Input(), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task Create_WhenTemplateMissing_Inactive_OrDeleted_ThrowsTemplateInvalid() + { + using var ctx = NewContext(); + + (await new Func>(() => NewService(ctx).CreateAsync( + Admin(), Input(requiresDoc: true, templateId: 999), CancellationToken.None)) + .Should().ThrowAsync()).Which.Code.Should().Be("TemplateInvalid"); + + ctx.CompletionDocTemplates.Add(Template("Inactive", active: false)); + ctx.CompletionDocTemplates.Add(Template("Deleted", deleted: true)); + ctx.SaveChanges(); + + (await new Func>(() => NewService(ctx).CreateAsync( + Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Inactive").Id), CancellationToken.None)) + .Should().ThrowAsync()).Which.Code.Should().Be("TemplateInvalid"); + + (await new Func>(() => NewService(ctx).CreateAsync( + Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Deleted").Id), CancellationToken.None)) + .Should().ThrowAsync()).Which.Code.Should().Be("TemplateInvalid"); + } + + [Fact] + public async Task Create_WithActiveTemplate_ProjectsLinkedTemplateSummary() + { + using var ctx = NewContext(); + var template = Template("Completion Packet"); + ctx.CompletionDocTemplates.Add(template); + ctx.SaveChanges(); + + var dto = await NewService(ctx).CreateAsync( + Admin(), Input(requiresDoc: true, templateId: template.Id), CancellationToken.None); + + dto.RequiresCompletionDocument.Should().BeTrue(); + dto.CompletionDocTemplate.Should().NotBeNull(); + dto.CompletionDocTemplate!.Id.Should().Be(template.Id); + dto.CompletionDocTemplate.Name.Should().Be("Completion Packet"); + } + + [Fact] + public async Task Create_WhenCompletionToggleOff_IgnoresTemplateLink() + { + using var ctx = NewContext(); + var template = Template("Completion Packet"); + ctx.CompletionDocTemplates.Add(template); + ctx.SaveChanges(); + + var dto = await NewService(ctx).CreateAsync( + Admin(), Input(requiresDoc: false, templateId: template.Id), CancellationToken.None); + + dto.RequiresCompletionDocument.Should().BeFalse(); + dto.CompletionDocTemplate.Should().BeNull(); + ctx.Services.Single().CompletionDocTemplateId.Should().BeNull(); + } + + [Theory] + [InlineData(WorkOrderType.PO)] + [InlineData(WorkOrderType.Project)] + [InlineData(WorkOrderType.Inspection)] + [InlineData(WorkOrderType.AddOn)] + [InlineData(WorkOrderType.Other)] + public async Task Create_WhenUnsupportedWorkOrderType_ThrowsWorkOrderTypeInvalid(WorkOrderType unsupported) + { + using var ctx = NewContext(); + var input = Input(types: new List { WorkOrderType.PM, unsupported }); + var act = () => NewService(ctx).CreateAsync(Admin(), input, CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("WorkOrderTypeInvalid"); + } + + [Fact] + public async Task Update_RewritesFieldsButPreservesActiveState() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None); + + var updated = await service.UpdateAsync( + Admin(), created.Id, Input("Leak Repair", trade: "HVAC", iconKey: "hvac"), CancellationToken.None); + + updated.Name.Should().Be("Leak Repair"); + updated.Trade.Should().Be("HVAC"); + updated.IconKey.Should().Be("hvac"); + updated.IsActive.Should().BeFalse("update preserves the persisted active state"); + } + + [Fact] + public async Task Update_WhenAdminRequestsActiveState_ReactivatesService() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Inactive service"), CancellationToken.None); + await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None); + + var input = Input("Inactive service"); + input.IsActive = true; + var updated = await service.UpdateAsync(Admin(), created.Id, input, CancellationToken.None); + + updated.IsActive.Should().BeTrue(); + ctx.Services.Single().IsActive.Should().BeTrue(); + } + + [Fact] + public async Task Update_WhenSchedulerRequestsActiveState_ThrowsForbidden() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Inactive service"), CancellationToken.None); + await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None); + + var input = Input("Inactive service"); + input.IsActive = true; + var act = () => service.UpdateAsync(Scheduler(), created.Id, input, CancellationToken.None); + + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + ctx.Services.Single().IsActive.Should().BeFalse(); + } + + [Fact] + public async Task Update_WhenNotFound_ThrowsNotFound() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).UpdateAsync(Admin(), 404, Input(), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("NotFound"); + } + + [Fact] + public async Task Update_WhenDuplicateNameElsewhere_ThrowsDuplicateName() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var first = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + await service.CreateAsync(Admin(), Input("Clog"), CancellationToken.None); + + var act = () => service.UpdateAsync(Admin(), first.Id, Input("clog"), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("DuplicateName"); + } + + [Fact] + public async Task Update_WhenCallerLacksRole_ThrowsForbidden_BeforeAnyMutation() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + + var act = () => service.UpdateAsync(Principal("User"), created.Id, Input("Changed"), CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + ctx.Services.Single().Name.Should().Be("Leak"); + } + + [Fact] + public async Task Deactivate_SoftStateChangeOnly_PreservesRow() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + + await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None); + + ctx.Services.Should().HaveCount(1, "deactivation never hard deletes"); + var byId = await service.GetByIdAsync(created.Id, CancellationToken.None); + byId.Should().NotBeNull(); + byId!.IsActive.Should().BeFalse(); + } + + [Fact] + public async Task Deactivate_WhenScheduler_ThrowsForbidden() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None); + + var act = () => service.DeactivateAsync(Scheduler(), created.Id, CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + ctx.Services.Single().IsActive.Should().BeTrue(); + } + + [Fact] + public async Task Deactivate_WhenNotFound_ThrowsNotFound() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).DeactivateAsync(Admin(), 404, CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("NotFound"); + } + + [Fact] + public async Task GetAll_FiltersByActiveStateAndWorkOrderType() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var pm = await service.CreateAsync(Admin(), Input("PM Service", types: new List { WorkOrderType.PM }), CancellationToken.None); + var reactive = await service.CreateAsync(Admin(), Input(name: "Reactive Service", trade: "HVAC", iconKey: "hvac", types: new List { WorkOrderType.Reactive }), CancellationToken.None); + await service.DeactivateAsync(Admin(), reactive.Id, CancellationToken.None); + + (await service.GetAllAsync(true, null, CancellationToken.None)).Select(s => s.Id) + .Should().BeEquivalentTo(new[] { pm.Id }); + (await service.GetAllAsync(false, null, CancellationToken.None)).Select(s => s.Id) + .Should().BeEquivalentTo(new[] { reactive.Id }); + + (await service.GetAllAsync(null, WorkOrderType.PM, CancellationToken.None)).Select(s => s.Id) + .Should().BeEquivalentTo(new[] { pm.Id }); + (await service.GetAllAsync(null, WorkOrderType.Emergency, CancellationToken.None)) + .Should().BeEmpty(); + } + + [Fact] + public async Task GetAll_WhenWorkOrderTypeFilterUnsupported_ThrowsWorkOrderTypeInvalid() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).GetAllAsync(null, WorkOrderType.Project, CancellationToken.None); + (await act.Should().ThrowAsync()).Which.Code.Should().Be("WorkOrderTypeInvalid"); + } + + [Fact] + public async Task GetById_WhenMissing_ReturnsNull() + { + using var ctx = NewContext(); + (await NewService(ctx).GetByIdAsync(99, CancellationToken.None)).Should().BeNull(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs new file mode 100644 index 0000000..d9ce2d7 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs @@ -0,0 +1,174 @@ +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 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()); + + 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 _); + + 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); + 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 Create_ConcurrentDuplicateEmail_ReturnsAlreadyInUseInsteadOf500() + { + var service = NewService(out var userManager, out var roleManager, 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) + { + userManager = UserManager(); + roleManager = RoleManager(); + areas = new Mock(); + overrides = new Mock(); + return new TeamMemberService(userManager.Object, roleManager.Object, areas.Object, overrides.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/TeamPermissionOverrideDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/TeamPermissionOverrideDataServiceTests.cs new file mode 100644 index 0000000..0f0861e --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/TeamPermissionOverrideDataServiceTests.cs @@ -0,0 +1,80 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public sealed class TeamPermissionOverrideDataServiceTests +{ + [Fact] + public async Task GetUserAsync_ReturnsIdentityRoleAndStoredOverrides() + { + await using var context = NewContext(); + SeedUserWithRole(context, "u1", "Dispatcher"); + context.UserPermissionOverrides.Add(new UserPermissionOverride + { + UserId = "u1", + PermissionKey = "deleteSites", + State = UserPermissionState.Deny + }); + await context.SaveChangesAsync(); + + var result = await new TeamPermissionOverrideDataService(context) + .GetUserAsync("u1", CancellationToken.None); + + result.Should().NotBeNull(); + result!.RoleName.Should().Be("Dispatcher"); + result.Overrides["deleteSites"].Should().Be(UserPermissionState.Deny); + } + + [Fact] + public async Task SetOverrideAsync_UpsertsOneCompositeKey() + { + await using var context = NewContext(); + SeedUserWithRole(context, "u1", "Scheduler"); + await context.SaveChangesAsync(); + var service = new TeamPermissionOverrideDataService(context); + + await service.SetOverrideAsync("u1", "deleteSites", UserPermissionState.Allow, CancellationToken.None); + await service.SetOverrideAsync("u1", "deleteSites", UserPermissionState.Deny, CancellationToken.None); + + var rows = await context.UserPermissionOverrides.ToListAsync(); + rows.Should().ContainSingle(); + rows[0].State.Should().Be(UserPermissionState.Deny); + } + + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static void SeedUserWithRole(ApplicationDbContext context, string userId, string roleName) + { + context.Users.Add(new ApplicationUser + { + Id = userId, + UserName = userId, + NormalizedUserName = userId.ToUpperInvariant(), + Email = userId + "@example.com", + NormalizedEmail = (userId + "@example.com").ToUpperInvariant() + }); + context.Roles.Add(new IdentityRole + { + Id = "role-1", + Name = roleName, + NormalizedName = roleName.ToUpperInvariant() + }); + context.UserRoles.Add(new IdentityUserRole + { + UserId = userId, + RoleId = "role-1" + }); + } +} diff --git a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs index abad676..7d49f24 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs @@ -52,6 +52,56 @@ public class UpliftControllerTests envelope.Status.Should().Be("Success"); } + [Fact] + public async Task List_PassesStatusAndTierFiltersToService() + { + var service = new Mock(); + service.Setup(x => x.ListAsync(It.IsAny(), "Approved", 2, 3, 50, It.IsAny())) + .ReturnsAsync(new UpliftListResultDTO()); + + var controller = NewController(service); + + await controller.List("Approved", 2, 3, 50); + + service.Verify( + x => x.ListAsync(It.IsAny(), "Approved", 2, 3, 50, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task List_ReturnsQueueContractFieldsInEnvelope() + { + var service = new Mock(); + var item = new UpliftListItemDTO + { + Id = 7, + WorkOrderId = 11, + WorkOrderNumber = "WO-77", + WorkOrderSite = "SITE-EAST", + WorkOrderService = "HVAC", + RequestedByName = "Gateway", + WorkOrderAutoApprovedTotal = 150m, + WorkOrderAdminApprovedTotal = 300m, + WorkOrderApprovedExposureTotal = 450m + }; + service.Setup(x => x.ListAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new UpliftListResultDTO { Total = 1, Page = 1, PageSize = 25, Items = new[] { item } }); + + var controller = NewController(service); + + var result = await controller.List(); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + var data = envelope.Data as UpliftListResultDTO; + var returned = data!.Items.Should().ContainSingle().Subject; + returned.WorkOrderNumber.Should().Be("WO-77"); + returned.WorkOrderSite.Should().Be("SITE-EAST"); + returned.WorkOrderService.Should().Be("HVAC"); + returned.RequestedByName.Should().Be("Gateway"); + returned.WorkOrderApprovedExposureTotal.Should().Be(450m); + } + [Fact] public async Task Approve_NotFound_Returns404() { @@ -67,6 +117,49 @@ public class UpliftControllerTests nf.Value.Should().BeOfType(); } + [Fact] + public async Task Revoke_WithReason_DelegatesToService() + { + var service = new Mock(); + service.Setup(x => x.RevokeAsync( + It.IsAny(), + 7, + "Policy change", + It.IsAny())) + .ReturnsAsync(new UpliftDecisionResultDTO { Id = 7, Status = "Revoked" }); + + var controller = NewController(service, "Admin"); + + var result = await controller.Revoke( + 7, + new UpliftController.DecisionRequest { Note = "Policy change" }); + + result.Should().BeOfType(); + service.Verify(x => x.RevokeAsync( + It.IsAny(), + 7, + "Policy change", + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Revoke_WithoutReason_ReturnsBadRequest() + { + var service = new Mock(); + service.Setup(x => x.RevokeAsync( + It.IsAny(), + 7, + string.Empty, + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("reason required")); + + var controller = NewController(service, "Admin"); + + var result = await controller.Revoke(7, null); + + result.Should().BeOfType(); + } + [Fact] public async Task Approve_Forbidden_ReturnsSanitized403AndLogsInternally() { diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs new file mode 100644 index 0000000..f5b0a5d --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs @@ -0,0 +1,723 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Moq; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +// Behavior tests for the approval queue read contract. These exercise the real +// service + data-service layers against an in-memory DbContext so that queue +// ordering, filters, flattened work-order fields, exposure aggregation, and +// per-tier read authorization are validated through the public contract. +public sealed class UpliftQueueReadTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static ApprovalsOptions NewOptions() => new() + { + UpliftTier1MaxUsd = 2500m, + Tier1Roles = new[] { "Approver" }, + Tier2Roles = new[] { "Manager" }, + Tier1NotificationRecipients = new[] { "tier1@example.com" }, + Tier2NotificationRecipients = new[] { "tier2@example.com" }, + EscalationRecipients = new[] { "escalate@example.com" } + }; + + private static UpliftService NewService(ApplicationDbContext context) => + new(new UpliftDataService(context), + new DispatchDataService(context), + Mock.Of(), + TimeProvider.System, + Microsoft.Extensions.Options.Options.Create(NewOptions())); + + private static ClaimsPrincipal UserWithRoles(params string[] roles) + { + var claims = new List { new(ClaimTypes.NameIdentifier, "user-42") }; + claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r))); + return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); + } + + private static DispatchUpliftRequest Request( + Dispatch dispatch, + string status, + DateTime created, + DateTime? decided = null, + int tier = 1, + decimal requested = 100m, + string? reason = null, + string? requestedBy = null, + string? createdBy = null, + decimal? currentNte = null) => new() + { + DispatchId = dispatch.Id, + Status = status, + CreatedDate = created, + DecidedAt = decided, + RequiredTier = tier, + RequestedNTE = requested, + CurrentNTE = currentNte, + VendorReason = reason, + RequestedByVendorName = requestedBy, + createdby = createdBy, + NotificationStatus = "Pending" + }; + + private static async Task<(Vendor Vendor, WorkOrder WorkOrder)> SeedWorkOrderAsync( + ApplicationDbContext context, + string number, + string site, + string service, + string vendorName = "Gateway") + { + var vendor = new Vendor { CompanyName = vendorName, IsActive = true }; + var workOrder = new WorkOrder + { + InternalWONumber = number, + WorkerOrderNumber = $"legacy-{number}", + SiteCode = site, + Service = service, + WorkerOrderTitle = "Repair" + }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + return (vendor, workOrder); + } + + private static async Task SeedDispatchAsync( + ApplicationDbContext context, Vendor vendor, WorkOrder workOrder, string number) + { + var dispatch = new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = workOrder.Id, + DispatchNumber = number, + Status = "Completed", + NTEAmount = 1000m + }; + context.Dispatches.Add(dispatch); + await context.SaveChangesAsync(); + return dispatch; + } + + // --- Ordering --- + + [Fact] + public async Task List_PendingStatus_OrdersOldestRequestFirst() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Pending", new DateTime(2026, 3, 3)), + Request(dispatch, "Pending", new DateTime(2026, 3, 1)), + Request(dispatch, "Pending", new DateTime(2026, 3, 2))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + result.Items.Select(i => i.RequestedAt).Should().Equal( + new DateTime(2026, 3, 1), + new DateTime(2026, 3, 2), + new DateTime(2026, 3, 3)); + } + + [Fact] + public async Task List_ApprovedStatus_OrdersMostRecentlyDecidedFirst() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 10)), + Request(dispatch, "Approved", new DateTime(2026, 3, 2), decided: new DateTime(2026, 3, 20)), + Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 15))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None); + + result.Items.Select(i => i.DecidedAt).Should().Equal( + new DateTime(2026, 3, 20), + new DateTime(2026, 3, 15), + new DateTime(2026, 3, 10)); + } + + [Fact] + public async Task List_WithoutStatusFilter_KeepsHistoricalNewestRequestFirstOrder() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)), + Request(dispatch, "Approved", new DateTime(2026, 3, 10), decided: new DateTime(2026, 3, 11))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), null, null, 1, 25, CancellationToken.None); + + result.Items.Select(i => i.RequestedAt).Should().ContainInOrder( + new DateTime(2026, 3, 10), + new DateTime(2026, 3, 1)); + } + + // --- Filters and scoping --- + + [Fact] + public async Task List_StatusAndTierFilters_Combine() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Pending", new DateTime(2026, 3, 1), tier: 1), + Request(dispatch, "Pending", new DateTime(2026, 3, 2), tier: 2), + Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), tier: 2)); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Manager"), "Pending", 2, 1, 25, CancellationToken.None); + + result.Total.Should().Be(1); + result.Items.Should().ContainSingle(i => i.RequiredTier == 2 && i.Status == "Pending"); + } + + [Fact] + public async Task List_ExcludesDeletedRequestsAndRequestsOnDeletedDispatches() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + var deletedDispatch = new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = workOrder.Id, + DispatchNumber = "DIS-DELETED", + Status = "Completed", + IsDeleted = true + }; + context.Dispatches.Add(deletedDispatch); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Pending", new DateTime(2026, 3, 1)), + new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + Status = "Pending", + CreatedDate = new DateTime(2026, 3, 2), + IsDeleted = true, + RequiredTier = 1, + RequestedNTE = 100m + }, + Request(deletedDispatch, "Pending", new DateTime(2026, 3, 3))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + result.Total.Should().Be(1); + result.Items.Should().ContainSingle(i => i.RequestedAt == new DateTime(2026, 3, 1)); + } + + // --- Flattened queue fields --- + + [Fact] + public async Task List_MapsFlattenedWorkOrderAndRequesterFields() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(Request( + dispatch, "Pending", new DateTime(2026, 3, 2), + tier: 2, requested: 400m, reason: "Scope grew", + requestedBy: "Gateway", createdBy: "user-7", currentNte: 100m)); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Manager"), "Pending", null, 1, 25, CancellationToken.None); + + var item = result.Items.Should().ContainSingle().Subject; + item.WorkOrderId.Should().Be(workOrder.Id); + item.WorkOrderNumber.Should().Be("WO-77"); + item.WorkOrderSite.Should().Be("SITE-EAST"); + item.WorkOrderService.Should().Be("HVAC"); + item.VendorCompanyName.Should().Be("Gateway"); + item.RequestedByName.Should().Be("Gateway"); + item.RequestedAt.Should().Be(new DateTime(2026, 3, 2)); + item.VendorReason.Should().Be("Scope grew"); + item.Delta.Should().Be(300m); + item.Status.Should().Be("Pending"); + } + + [Fact] + public async Task List_WorkOrderNumber_RendersInternalShNumber_NotTheCrmExternalId() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + // Synced work orders carry the CRM external id in WorkerOrderNumber; the SH + // display number the board renders is stamped on InternalWONumber. + var workOrder = new WorkOrder + { + InternalWONumber = "10000000001", + WorkerOrderNumber = "CRM-EXT-77", + SiteCode = "SITE-EAST", + Service = "HVAC", + WorkerOrderTitle = "Repair" + }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 3, 2))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + var item = result.Items.Should().ContainSingle().Subject; + item.WorkOrderNumber.Should().Be("10000000001"); + item.WorkOrderNumber.Should().NotBe("CRM-EXT-77"); + } + + [Fact] + public async Task List_RequesterLabelFallsBackToCreatingUserName() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(Request( + dispatch, "Pending", new DateTime(2026, 3, 2), + requestedBy: null, createdBy: "user-7")); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + result.Items.Single().RequestedByName.Should().Be("Ada Smith"); + } + + [Fact] + public async Task List_MapsEvidenceAttachmentSummary() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + var evidence = new VendorCompletionDocument + { + VendorId = vendor.Id, + DispatchId = dispatch.Id, + WorkOrderId = workOrder.Id, + OriginalFileName = "quote.pdf", + StoredFileName = "evidence.bin", + ContentType = "application/pdf", + SizeBytes = 512, + ScanStatus = "Passed", + ReviewStatus = "Approved", + Purpose = "UpliftEvidence", + Version = 1 + }; + context.VendorCompletionDocuments.Add(evidence); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + Status = "Pending", + CreatedDate = new DateTime(2026, 3, 2), + RequiredTier = 1, + RequestedNTE = 200m, + EvidenceDocumentId = evidence.Id + }); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + var item = result.Items.Single(); + item.EvidenceDocumentId.Should().Be(evidence.Id); + item.EvidenceFileName.Should().Be("quote.pdf"); + item.EvidenceContentType.Should().Be("application/pdf"); + item.EvidenceSizeBytes.Should().Be(512); + } + + // --- Queue contract fields (SH-208/SH-213) --- + + [Fact] + public async Task List_MarksWorkOrderClosed_OnlyForTerminalLifecycle() + { + using var context = NewContext(); + var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); + var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Plumbing"); + workOrderA.LifecycleStatus = LifecycleStatus.Completed; + workOrderB.LifecycleStatus = LifecycleStatus.Canceled; + await context.SaveChangesAsync(); + var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); + var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); + var (vendorC, workOrderC) = await SeedWorkOrderAsync(context, "WO-C", "SITE-C", "Electrical"); + var dispatchC = await SeedDispatchAsync(context, vendorC, workOrderC, "DIS-C"); + context.DispatchUpliftRequests.AddRange( + Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)), + Request(dispatchB, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4)), + Request(dispatchC, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Admin"), "Approved", null, 1, 25, CancellationToken.None); + + result.Items.Single(i => i.WorkOrderNumber == "WO-A").WorkOrderClosed.Should().BeTrue(); + result.Items.Single(i => i.WorkOrderNumber == "WO-B").WorkOrderClosed.Should().BeTrue(); + result.Items.Single(i => i.WorkOrderNumber == "WO-C").WorkOrderClosed.Should().BeFalse(); + } + + [Fact] + public async Task List_ApprovedRow_CarriesDecidedByNameFromDecidingUser() + { + 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.Users.Add(new ApplicationUser { Id = "user-9", FirstName = "Grace", LastName = "Hopper" }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + Status = "Approved", + CreatedDate = new DateTime(2026, 3, 1), + DecidedAt = new DateTime(2026, 3, 2), + DecidedByUserId = "user-9", + RequiredTier = 1, + RequestedNTE = 300m, + NotificationStatus = "Pending" + }); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None); + + result.Items.Single().DecidedByName.Should().Be("Grace Hopper"); + } + + [Fact] + public async Task List_AttachmentCount_CountsActiveDocumentsOnTheDispatch() + { + 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.VendorCompletionDocuments.AddRange( + new VendorCompletionDocument + { + VendorId = vendor.Id, + DispatchId = dispatch.Id, + WorkOrderId = workOrder.Id, + OriginalFileName = "quote.pdf", + StoredFileName = "a.bin", + ContentType = "application/pdf", + SizeBytes = 512, + ScanStatus = "Passed", + ReviewStatus = "Approved", + Purpose = "UpliftEvidence", + Version = 1 + }, + new VendorCompletionDocument + { + VendorId = vendor.Id, + DispatchId = dispatch.Id, + WorkOrderId = workOrder.Id, + OriginalFileName = "photo.jpg", + StoredFileName = "b.bin", + ContentType = "image/jpeg", + SizeBytes = 2048, + ScanStatus = "Passed", + ReviewStatus = "Approved", + Purpose = "Completion", + Version = 1 + }, + new VendorCompletionDocument + { + VendorId = vendor.Id, + DispatchId = dispatch.Id, + WorkOrderId = workOrder.Id, + OriginalFileName = "old.pdf", + StoredFileName = "c.bin", + ContentType = "application/pdf", + SizeBytes = 128, + ScanStatus = "Passed", + ReviewStatus = "Approved", + Purpose = "Completion", + Version = 1, + IsDeleted = true + }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 3, 2))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + result.Items.Single().AttachmentCount.Should().Be(2); + } + + [Fact] + public async Task List_PendingExposureTotal_SumsGrantedAmountsAcrossAllPendingRequests() + { + 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.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.AddRange( + // Vendor-portal rows (createdby null) store the requested new NTE total, + // so the pending exposure is the delta: 400 - 100 = 300 and 600 - 250 = 350. + Request(dispatch, "Pending", new DateTime(2026, 3, 1), requested: 400m, currentNte: 100m), + Request(dispatch, "Pending", new DateTime(2026, 3, 2), requested: 600m, currentNte: 250m), + // Work-order-path rows (createdby set) store the requested increment: 90. + Request(dispatch, "Pending", new DateTime(2026, 3, 3), requested: 90m, currentNte: 600m, createdBy: "user-7"), + // Non-pending rows never add pending exposure. + Request(dispatch, "Approved", new DateTime(2026, 2, 1), decided: new DateTime(2026, 2, 2), requested: 500m, currentNte: 100m)); + await context.SaveChangesAsync(); + var service = NewService(context); + + // Page 1 with a single row still reports the total across ALL pending rows. + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 1, CancellationToken.None); + + result.Items.Should().HaveCount(1); + result.PendingExposureTotal.Should().Be(740m); + } + + // --- Approved-on-WO exposure totals --- + + [Fact] + public async Task List_AggregatesApprovedExposurePerWorkOrder_ExcludingNonApprovedStatuses() + { + using var context = NewContext(); + var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC", "Gateway-A"); + var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Plumbing", "Gateway-B"); + var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); + var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); + context.DispatchUpliftRequests.AddRange( + // Vendor-portal row (createdby null): stores the requested new NTE total, + // so the granted amount is 400 - 100 = 300. + Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 400m, currentNte: 100m), + new DispatchUpliftRequest + { + DispatchId = dispatchA.Id, + Status = "NoApprovalRequired", + CreatedDate = new DateTime(2026, 3, 1), + RequiredTier = 0, + RequestedNTE = 150m + }, + Request(dispatchA, "Pending", new DateTime(2026, 3, 1), requested: 999m), + Request(dispatchA, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 999m), + new DispatchUpliftRequest + { + DispatchId = dispatchA.Id, + Status = "Withdrawn", + CreatedDate = new DateTime(2026, 3, 1), + DecidedAt = new DateTime(2026, 3, 2), + RequiredTier = 1, + RequestedNTE = 999m + }, + new DispatchUpliftRequest + { + DispatchId = dispatchA.Id, + Status = "Revoked", + CreatedDate = new DateTime(2026, 3, 1), + DecidedAt = new DateTime(2026, 3, 2), + RequiredTier = 1, + RequestedNTE = 999m + }, + new DispatchUpliftRequest + { + DispatchId = dispatchA.Id, + Status = "Expired", + CreatedDate = new DateTime(2026, 3, 1), + RequiredTier = 1, + RequestedNTE = 999m + }, + Request(dispatchB, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 75m, currentNte: 0m)); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None); + + var itemA = result.Items.Single(i => i.WorkOrderNumber == "WO-A"); + itemA.WorkOrderAutoApprovedTotal.Should().Be(150m); + itemA.WorkOrderAdminApprovedTotal.Should().Be(300m); + itemA.WorkOrderApprovedExposureTotal.Should().Be(450m); + + var itemB = result.Items.Single(i => i.WorkOrderNumber == "WO-B"); + itemB.WorkOrderAutoApprovedTotal.Should().Be(0m); + itemB.WorkOrderAdminApprovedTotal.Should().Be(75m); + itemB.WorkOrderApprovedExposureTotal.Should().Be(75m); + } + + [Fact] + public async Task Exposure_UsesGrantedAmountForVendorPortalAndRequestedAmountForWorkOrderRequests() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-ORIGIN", "SITE-A", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-ORIGIN"); + context.DispatchUpliftRequests.AddRange( + Request( + dispatch, + "Approved", + new DateTime(2026, 3, 1), + decided: new DateTime(2026, 3, 2), + requested: 400m, + currentNte: 100m), + Request( + dispatch, + "Approved", + new DateTime(2026, 3, 3), + decided: new DateTime(2026, 3, 4), + requested: 250m, + createdBy: "internal-user", + currentNte: 100m)); + await context.SaveChangesAsync(); + + var exposure = await new UpliftDataService(context) + .GetApprovedExposureForWorkOrdersAsync(new[] { workOrder.Id }, CancellationToken.None); + + exposure.Should().ContainSingle().Which.AdminApprovedTotal.Should().Be(550m); + } + + [Fact] + public async Task Exposure_IncludesLinkedDispatchesViaServerDerivedWorkOrderLinks() + { + using var context = NewContext(); + var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); + var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); + var linkedDispatch = new Dispatch + { + VendorId = vendorA.Id, + DispatchNumber = "DIS-LINKED", + Status = "Completed" + }; + context.Dispatches.Add(linkedDispatch); + await context.SaveChangesAsync(); + context.DispatchWorkOrders.Add(new DispatchWorkOrder + { + DispatchId = linkedDispatch.Id, + WorkOrderId = workOrderA.Id + }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(Request( + linkedDispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 60m)); + await context.SaveChangesAsync(); + var data = new UpliftDataService(context); + + var exposure = await data.GetApprovedExposureForWorkOrdersAsync( + new[] { workOrderA.Id }, CancellationToken.None); + + var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrderA.Id).Subject; + total.AdminApprovedTotal.Should().Be(60m); + total.AutoApprovedTotal.Should().Be(0m); + } + + [Fact] + public async Task Exposure_AdminApprovedSumsGrantedAmountsPerCreationPath() + { + 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, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 1500m, currentNte: 1000m), + Request(dispatch, "Approved", 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, "Approved", 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.AdminApprovedTotal.Should().Be(1000m); + 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] + public async Task List_CanDecide_ReflectsTierRolesForTheCaller() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Pending", new DateTime(2026, 3, 1), tier: 1), + Request(dispatch, "Pending", new DateTime(2026, 3, 2), tier: 2)); + await context.SaveChangesAsync(); + var service = NewService(context); + + var tier1Only = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + var tier2User = await service.ListAsync(UserWithRoles("Manager"), "Pending", null, 1, 25, CancellationToken.None); + + tier1Only.Items.Single(i => i.RequiredTier == 1).CanDecide.Should().BeTrue(); + tier1Only.Items.Single(i => i.RequiredTier == 2).CanDecide.Should().BeFalse(); + tier2User.Items.Single(i => i.RequiredTier == 2).CanDecide.Should().BeTrue(); + tier2User.Items.Single(i => i.RequiredTier == 1).CanDecide.Should().BeFalse(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs b/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs index 9fa989c..a420208 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Options; using Moq; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using Xunit; @@ -13,6 +14,58 @@ namespace Api.SeaHavenIndustries.Tests; public sealed class UpliftServiceRefusedTests { + [Fact] + public async Task RevokeAsync_DelegatesApprovedAdminRevocationToWorkOrderFlow() + { + var request = new DispatchUpliftRequest + { + Id = 7, + DispatchId = 42, + Status = "Approved", + RequiredTier = 1, + RequestedNTE = 900m + }; + var upliftData = new Mock(); + upliftData.Setup(data => data.GetByIdAsync(7, It.IsAny())) + .ReturnsAsync(request); + upliftData.Setup(data => data.GetWorkOrderIdForUpliftAsync(7, It.IsAny())) + .ReturnsAsync(77); + var workOrderFlow = new Mock(); + workOrderFlow.Setup(flow => flow.RevokeAsync( + 77, + 7, + It.Is(r => r.Reason == "Policy change"), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new WorkOrderUpliftDto { Id = 7, Status = "revoked" }); + + var service = new UpliftService( + upliftData.Object, + Mock.Of(), + Mock.Of(), + TimeProvider.System, + Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()), + workOrderFlow.Object); + var user = new ClaimsPrincipal(new ClaimsIdentity( + new[] + { + new Claim(ClaimTypes.NameIdentifier, "admin-1"), + new Claim(ClaimTypes.Role, "Admin") + }, + "test")); + + var result = await service.RevokeAsync(user, 7, " Policy change ", CancellationToken.None); + + result.Id.Should().Be(7); + result.Status.Should().Be("Revoked"); + workOrderFlow.Verify(flow => flow.RevokeAsync( + 77, + 7, + It.Is(r => r.Reason == "Policy change"), + It.IsAny(), + It.IsAny()), Times.Once); + } + [Fact] public async Task ApproveAsync_RefusedDispatch_RejectsWithoutChangingNteOrRequest() { 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.Tests/VendorCompanyRosterControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs index b10a37d..e57a307 100644 --- a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs @@ -105,14 +105,14 @@ public class VendorCompanyRosterControllerTests var result = await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); result.Should().BeOfType(); - service.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + service.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Create_Returns200() { var service = new Mock(); - service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny(), It.IsAny())) .ReturnsAsync(SampleRoster()); var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); @@ -125,7 +125,7 @@ public class VendorCompanyRosterControllerTests public async Task Create_ValidationException_Returns400() { var service = new Mock(); - service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new ValidationException("At least one company phone or email is required.")); var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme" }, CancellationToken.None); @@ -137,7 +137,7 @@ public class VendorCompanyRosterControllerTests public async Task Create_DuplicateName_ReturnsStable409() { var service = new Mock(); - service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new VendorRosterDuplicateNameException( "database detail", new InvalidOperationException("IX_VendorCompanies_NormalizedName"))); @@ -164,14 +164,14 @@ public class VendorCompanyRosterControllerTests var result = await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); result.Should().BeOfType(); - service.Verify(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + service.Verify(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Reconcile_ValidationException_Returns400() { var service = new Mock(); - service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new ValidationException("Duplicate technician ids are not allowed.")); var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); @@ -183,7 +183,7 @@ public class VendorCompanyRosterControllerTests public async Task Reconcile_CompanyNotFound_Returns404() { var service = new Mock(); - service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new KeyNotFoundException("not found")); var result = await NewController(service).Reconcile(404, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); @@ -199,7 +199,7 @@ public class VendorCompanyRosterControllerTests new() { WorkOrderId = 500, WorkOrderNumber = "WO-500", LifecycleStatus = LifecycleStatus.Scheduled } }; var service = new Mock(); - service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new VendorRosterConflictException("blocked", blocked)); var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); @@ -212,7 +212,7 @@ public class VendorCompanyRosterControllerTests public async Task Reconcile_StaleRowVersion_ReturnsStable409WithoutExceptionText() { var service = new Mock(); - service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ...")); var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); @@ -251,14 +251,14 @@ public class VendorCompanyRosterControllerTests var result = await controller.AddTechnicians(7, PatchDto(), CancellationToken.None); result.Should().BeOfType(); - service.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + service.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Patch_AddsTechnicians_Returns200WithRoster() { var service = new Mock(); - service.Setup(x => x.AddTechniciansAsync(7, It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.AddTechniciansAsync(7, It.IsAny(), "42", It.IsAny(), It.IsAny())) .ReturnsAsync(SampleRoster()); var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None); @@ -272,7 +272,7 @@ public class VendorCompanyRosterControllerTests public async Task Patch_ValidationException_Returns400() { var service = new Mock(); - service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new ValidationException("Technician ids are not allowed when adding technicians.")); var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None); @@ -285,7 +285,7 @@ public class VendorCompanyRosterControllerTests public async Task Patch_CompanyNotFound_Returns404() { var service = new Mock(); - service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new KeyNotFoundException("not found")); var result = await NewController(service).AddTechnicians(404, PatchDto(), CancellationToken.None); @@ -297,7 +297,7 @@ public class VendorCompanyRosterControllerTests public async Task Patch_StaleRowVersion_ReturnsStable409WithoutExceptionText() { var service = new Mock(); - service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ...")); var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None); @@ -306,4 +306,29 @@ public class VendorCompanyRosterControllerTests conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); conflict.Value!.ToString()!.Should().NotContain("internal provider detail"); } + + [Fact] + public async Task AreaAssignmentForbidden_Returns403OnEveryMutation() + { + var service = new Mock(); + service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny(), It.IsAny())) + .ThrowsAsync(new VendorAreaAssignmentForbiddenException()); + service.Setup(x => x.ReconcileRosterAsync(7, It.IsAny(), "42", It.IsAny(), It.IsAny())) + .ThrowsAsync(new VendorAreaAssignmentForbiddenException()); + service.Setup(x => x.AddTechniciansAsync(7, It.IsAny(), "42", It.IsAny(), It.IsAny())) + .ThrowsAsync(new VendorAreaAssignmentForbiddenException()); + var controller = NewController(service); + + var results = new[] + { + await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 }, CancellationToken.None), + await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", AreaId = 1 }, CancellationToken.None), + await controller.AddTechnicians(7, new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" }, CancellationToken.None) + }; + + foreach (var result in results) + result.Should().BeOfType().Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + + service.Verify(x => x.ReconcileRosterAsync(7, It.IsAny(), "42", controller.User, It.IsAny()), Times.Once); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs index 6114c31..cd7627c 100644 --- a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs @@ -835,4 +835,43 @@ public class VendorCompanyRosterDataServiceTests await act.Should().ThrowAsync(); } + + [Fact] + public async Task SaveRoster_AppliesAreaOnlyWhenProvidedAndReadsAreaName() + { + var dbName = Guid.NewGuid().ToString(); + using var context = NewContext(dbName); + context.Areas.AddRange( + new Area { Id = 1, Name = "East", NormalizedName = "east" }, + new Area { Id = 2, Name = "Central", NormalizedName = "central" }); + await context.SaveChangesAsync(); + var (companyId, _) = await SeedCompanyWithTechnicianAsync(context, "Area Co", "Tech"); + var service = new VendorCompanyRosterDataService(context); + + VendorCompanyRosterWriteModel Snapshot(bool provided, int? areaId) => new() + { + CompanyId = companyId, + Name = "Area Co", + Email = "area@example.com", + AreaIdProvided = provided, + AreaId = areaId, + Technicians = context.Vendors.Where(v => v.CompanyId == companyId) + .Select(v => new RosterTechnicianWriteModel { Id = v.Id, ContactName = v.ContactName }) + .ToList() + }; + + var assigned = await service.SaveRosterAsync(Snapshot(true, 2), CancellationToken.None); + assigned.AreaId.Should().Be(2); + assigned.AreaName.Should().Be("Central"); + + var untouched = await service.SaveRosterAsync(Snapshot(false, null), CancellationToken.None); + untouched.AreaId.Should().Be(2); + + var cleared = await service.SaveRosterAsync(Snapshot(true, null), CancellationToken.None); + cleared.AreaId.Should().BeNull(); + cleared.AreaName.Should().BeNull(); + + (await service.IsActiveAreaAsync(1, CancellationToken.None)).Should().BeTrue(); + (await service.IsActiveAreaAsync(42, CancellationToken.None)).Should().BeFalse(); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs index 5991fd5..72b29db 100644 --- a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs @@ -3,6 +3,7 @@ using FluentAssertions; using FluentValidation; using Microsoft.Extensions.DependencyInjection; using Moq; +using System.Security.Claims; using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; @@ -13,6 +14,13 @@ namespace Api.SeaHavenIndustries.Tests; public class VendorCompanyRosterServiceTests { + private static readonly ClaimsPrincipal Dispatcher = Principal("User"); + private static readonly ClaimsPrincipal Admin = Principal("Admin"); + + private static ClaimsPrincipal Principal(string role) => new(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, "42"), new Claim(ClaimTypes.Role, role) }, + "Test")); + private static VendorCompanyRosterService NewService(Mock data) => new(data.Object); private static VendorCompanyRosterReadModel SampleReadModel(int companyId = 7, params int[] technicianIds) => new() @@ -48,7 +56,7 @@ public class VendorCompanyRosterServiceTests var data = new Mock(); var dto = new CreateVendorRosterDTO { Name = "Acme" }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -60,7 +68,7 @@ public class VendorCompanyRosterServiceTests var data = new Mock(); var dto = new CreateVendorRosterDTO { Name = "Acme", CompanyPhone = "not-a-phone" }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.CompanyPhone)); @@ -78,7 +86,7 @@ public class VendorCompanyRosterServiceTests Technicians = new List { new() { Id = 7, ContactName = "Riley" } } }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -98,7 +106,7 @@ public class VendorCompanyRosterServiceTests Name = "Acme", Email = "acme@example.com", Technicians = new List() - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured.Should().NotBeNull(); captured!.Technicians.Should().BeEmpty(); @@ -120,7 +128,7 @@ public class VendorCompanyRosterServiceTests Name = "Acme", Email = "acme@example.com", Notes = notes - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured!.Notes.Should().Be(notes); } @@ -136,7 +144,7 @@ public class VendorCompanyRosterServiceTests Notes = new string('n', 501) }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(error => @@ -163,7 +171,7 @@ public class VendorCompanyRosterServiceTests } }; - var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -188,7 +196,7 @@ public class VendorCompanyRosterServiceTests } }; - var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -205,7 +213,7 @@ public class VendorCompanyRosterServiceTests GoogleMapsUrl = "http://maps.google.com/acme" }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorRosterDTO.GoogleMapsUrl)); @@ -228,7 +236,7 @@ public class VendorCompanyRosterServiceTests { new() { ContactName = "Riley", Phone = "+1 312 555 0100" } } - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured!.Technicians.Single().Phone.Should().Be("(312) 555-0100"); } @@ -247,7 +255,7 @@ public class VendorCompanyRosterServiceTests } }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone))); @@ -270,7 +278,7 @@ public class VendorCompanyRosterServiceTests Name = "Acme Co", Email = "acme@example.com", Technicians = new List { new() { Id = 1, ContactName = "T" } } - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured.Should().NotBeNull(); captured!.CompanyId.Should().Be(7); @@ -294,7 +302,7 @@ public class VendorCompanyRosterServiceTests Name = "Acme", Email = "acme@example.com", Notes = notes - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured!.Notes.Should().Be(notes); } @@ -311,7 +319,7 @@ public class VendorCompanyRosterServiceTests Notes = new string('n', 501) }; - var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(error => @@ -336,7 +344,7 @@ public class VendorCompanyRosterServiceTests Email = "acme@example.com" }; - var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion)); @@ -354,7 +362,7 @@ public class VendorCompanyRosterServiceTests Email = "acme@example.com" }; - var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(error => error.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion)); @@ -376,7 +384,7 @@ public class VendorCompanyRosterServiceTests Technicians = null! }; - await NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + await NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); captured!.Technicians.Should().BeEmpty(); } @@ -397,7 +405,7 @@ public class VendorCompanyRosterServiceTests } }; - var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -420,7 +428,7 @@ public class VendorCompanyRosterServiceTests { new() { ContactName = "", Phone = "", PreferredContact = "Phone" } } - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured!.Technicians.Should().ContainSingle(); captured.Technicians.Single().ContactName.Should().BeEmpty(); @@ -442,7 +450,7 @@ public class VendorCompanyRosterServiceTests Technicians = new List { new() { Id = 1, ContactName = "T" } } }; - var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", CancellationToken.None); + var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); } @@ -462,7 +470,7 @@ public class VendorCompanyRosterServiceTests Name = "Acme", Email = "acme@example.com", Technicians = new List { new() { Id = 1, ContactName = "T" } } - }, "42", cts.Token); + }, "42", Dispatcher, cts.Token); data.Verify(x => x.SaveRosterAsync(It.IsAny(), cts.Token), Times.Once); } @@ -477,7 +485,7 @@ public class VendorCompanyRosterServiceTests public async Task AddTechnicians_WithoutAuthenticatedUser_Throws() { var data = new Mock(); - var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny()), Times.Never); } @@ -488,7 +496,7 @@ public class VendorCompanyRosterServiceTests var data = new Mock(); var dto = AddDto(new RosterTechnicianInputDTO { Id = 999, ContactName = "Foreign" }); - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.AddTechnicians)); @@ -501,7 +509,7 @@ public class VendorCompanyRosterServiceTests var data = new Mock(); var dto = new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" }; - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -517,7 +525,7 @@ public class VendorCompanyRosterServiceTests AddTechnicians = new List { new() { ContactName = "T" } } }; - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.RowVersion)); @@ -530,7 +538,7 @@ public class VendorCompanyRosterServiceTests var data = new Mock(); var dto = AddDto(new RosterTechnicianInputDTO { ContactName = "T", Phone = "555-1234" }); - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone))); @@ -560,7 +568,7 @@ public class VendorCompanyRosterServiceTests } }; - await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); captured.Should().NotBeNull(); captured!.CompanyId.Should().Be(7); @@ -590,7 +598,7 @@ public class VendorCompanyRosterServiceTests { RowVersion = "AAAAAAAAD8I=", CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = notes } - }, "42", CancellationToken.None); + }, "42", Dispatcher, CancellationToken.None); captured!.CompanyFields!.Notes.Should().Be(notes); } @@ -605,7 +613,7 @@ public class VendorCompanyRosterServiceTests CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = new string('n', 501) } }; - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(error => @@ -632,7 +640,7 @@ public class VendorCompanyRosterServiceTests CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" } }; - await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); // SH-250: every field is blank, so there is no company change to apply. Forwarding a // non-null write model made the data layer bump RowVersion and rewrite every @@ -652,7 +660,7 @@ public class VendorCompanyRosterServiceTests CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" } }; - var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify( @@ -676,7 +684,7 @@ public class VendorCompanyRosterServiceTests CompanyFields = new VendorRosterCompanyFieldsDTO { City = "Norfolk" } }; - await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); captured!.CompanyFields.Should().NotBeNull(); captured.CompanyFields!.City.Should().Be("Norfolk"); @@ -690,8 +698,182 @@ public class VendorCompanyRosterServiceTests .ReturnsAsync(SampleReadModel(7, 1)); using var cts = new CancellationTokenSource(); - await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", cts.Token); + await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", Dispatcher, cts.Token); data.Verify(x => x.AddTechniciansAsync(It.IsAny(), cts.Token), Times.Once); } + + private static VendorCompanyRosterReadModel ReadModelWithArea(int? areaId) + { + var model = SampleReadModel(7, 1); + model.AreaId = areaId; + return model; + } + + [Fact] + public async Task Create_NonAdminAssigningArea_IsForbiddenAndNotPersisted() + { + var data = new Mock(); + var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 }; + + var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); + + await act.Should().ThrowAsync(); + data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_AdminAssigningUnknownOrArchivedArea_ThrowsValidation() + { + var data = new Mock(); + data.Setup(x => x.IsActiveAreaAsync(99, It.IsAny())).ReturnsAsync(false); + var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 99 }; + + var act = () => NewService(data).CreateRosterAsync(dto, "42", Admin, CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.AreaId)); + data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_AdminAssigningActiveArea_PersistsAreaId() + { + using var cts = new CancellationTokenSource(); + var data = new Mock(); + data.Setup(x => x.IsActiveAreaAsync(2, cts.Token)).ReturnsAsync(true); + VendorCompanyRosterWriteModel? captured = null; + data.Setup(x => x.CreateRosterAsync(It.IsAny(), It.IsAny())) + .Callback((model, _) => captured = model) + .ReturnsAsync(ReadModelWithArea(2)); + + var result = await NewService(data).CreateRosterAsync( + new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 2 }, + "42", + Admin, + cts.Token); + + captured!.AreaId.Should().Be(2); + result.AreaId.Should().Be(2); + data.Verify(x => x.IsActiveAreaAsync(2, cts.Token), Times.Once); + } + + [Fact] + public async Task Reconcile_NonAdminChangingOrClearingArea_IsForbiddenAndNotPersisted() + { + var data = new Mock(); + data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) + .ReturnsAsync(ReadModelWithArea(1)); + + foreach (int? requested in new int?[] { 3, null }) + { + var dto = new ReconcileVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + Name = "Acme", + Email = "acme@example.com", + AreaId = requested + }; + + var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + data.Verify(x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Reconcile_NonAdminRoundTripOrOmittedArea_SavesOtherFields() + { + var data = new Mock(); + data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) + .ReturnsAsync(ReadModelWithArea(1)); + var captured = new List(); + data.Setup(x => x.SaveRosterAsync(It.IsAny(), It.IsAny())) + .Callback((model, _) => captured.Add(model)) + .ReturnsAsync(ReadModelWithArea(1)); + + await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + Name = "Acme Renamed", + Email = "acme@example.com", + AreaId = 1 + }, "42", Dispatcher, CancellationToken.None); + + await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + Name = "Acme Renamed", + Email = "acme@example.com" + }, "42", Dispatcher, CancellationToken.None); + + captured.Should().HaveCount(2); + captured[0].AreaIdProvided.Should().BeTrue(); + captured[0].AreaId.Should().Be(1); + captured[1].AreaIdProvided.Should().BeFalse(); + } + + [Fact] + public async Task Reconcile_AdminClearsAreaToUnassigned() + { + var data = new Mock(); + data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) + .ReturnsAsync(ReadModelWithArea(1)); + VendorCompanyRosterWriteModel? captured = null; + data.Setup(x => x.SaveRosterAsync(It.IsAny(), It.IsAny())) + .Callback((model, _) => captured = model) + .ReturnsAsync(ReadModelWithArea(null)); + + await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + Name = "Acme", + Email = "acme@example.com", + AreaId = null + }, "42", Admin, CancellationToken.None); + + captured!.AreaIdProvided.Should().BeTrue(); + captured.AreaId.Should().BeNull(); + data.Verify(x => x.IsActiveAreaAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task AddTechnicians_NonAdminAreaChange_IsForbidden() + { + var data = new Mock(); + data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) + .ReturnsAsync(ReadModelWithArea(null)); + var dto = new AddTechniciansVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + CompanyFields = new VendorRosterCompanyFieldsDTO { AreaId = 2 } + }; + + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); + + await act.Should().ThrowAsync(); + data.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public void ReconcileDto_DistinguishesOmittedAreaFromExplicitNull() + { + var omitted = System.Text.Json.JsonSerializer.Deserialize( + """{"rowVersion":"AAAAAAAAD8I=","name":"Acme"}""", + new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!; + var cleared = System.Text.Json.JsonSerializer.Deserialize( + """{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":null}""", + new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!; + var assigned = System.Text.Json.JsonSerializer.Deserialize( + """{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":3}""", + new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!; + + omitted.AreaIdProvided.Should().BeFalse(); + cleared.AreaIdProvided.Should().BeTrue(); + cleared.AreaId.Should().BeNull(); + assigned.AreaIdProvided.Should().BeTrue(); + assigned.AreaId.Should().Be(3); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs index 99b7d1d..e0d34e0 100644 --- a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs @@ -72,6 +72,7 @@ public class VendorControllerTests It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), + It.IsAny?>(), It.IsAny()), Times.Never); } @@ -155,6 +156,7 @@ public class VendorControllerTests It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), + It.IsAny?>(), token)) .ReturnsAsync(new PagedResult { @@ -203,6 +205,7 @@ public class VendorControllerTests It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), + It.IsAny?>(), token), Times.Once); service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync( It.IsAny(), diff --git a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs index 7e7716a..4fc5ee1 100644 --- a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs @@ -470,12 +470,14 @@ public class VendorDataServiceTests search: "alice", trades: new[] { "Plumbing" }, locations: new[] { "Chicago, IL" }, - jobBuckets: new[] { "under-50" }).ToQueryString(); + jobBuckets: new[] { "under-50" }, + areaFilter: new VendorAreaFilter { AreaIds = new[] { 1, 2 }, IncludeUnassigned = true }).ToQueryString(); sql.Should().Contain("GROUP BY"); sql.Should().Contain("LEFT JOIN"); sql.Should().Contain("TradeSpecialties"); sql.Should().Contain("TotalJobs"); + sql.Should().Contain("AreaId"); Regex.IsMatch(sql, @"SUM\s*\(\s*\(\s*SELECT", RegexOptions.IgnoreCase) .Should().BeFalse("company totals must sum pre-aggregated scalar job counts"); } @@ -1044,4 +1046,69 @@ public class VendorDataServiceTests sql.Should().Contain("SELECT"); sql.Should().Contain("VendorCompanies"); } + + [Fact] + public async Task GetCompanyDirectoryPagedAsync_AreaFilterOrsWithinFacetAndUnassignedIncludesLegacyRows() + { + await using var context = NewContext(); + var east = new Area { Id = 1, Name = "East", NormalizedName = "east" }; + var west = new Area { Id = 3, Name = "West", NormalizedName = "west" }; + context.Areas.AddRange(east, west); + var eastCo = new VendorCompany { Name = "East Co", NormalizedName = "east co", Area = east }; + var westCo = new VendorCompany { Name = "West Co", NormalizedName = "west co", Area = west }; + var unassignedCo = new VendorCompany { Name = "Unassigned Co", NormalizedName = "unassigned co" }; + var eastOnly = new VendorCompany { Name = "East Only", NormalizedName = "east only", Area = east }; + context.VendorCompanies.AddRange(eastCo, westCo, unassignedCo, eastOnly); + context.Vendors.AddRange( + new Vendor { Company = eastCo, CompanyName = "East Co", ContactName = "E", City = "Boston", State = "MA", IsActive = true, IsDeleted = false }, + new Vendor { Company = westCo, CompanyName = "West Co", ContactName = "W", IsActive = true, IsDeleted = false }, + new Vendor { Company = unassignedCo, CompanyName = "Unassigned Co", ContactName = "U", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "Legacy Co", ContactName = "L", IsActive = true, IsDeleted = false }); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var eastResult = await service.GetCompanyDirectoryPagedAsync( + 1, 50, isActive: true, areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id } }); + eastResult.Items.Select(item => item.CompanyName) + .Should().BeEquivalentTo("East Co", "East Only"); + eastResult.TotalCount.Should().Be(2); + var eastRow = eastResult.Items.Single(item => item.CompanyName == "East Co"); + eastRow.AreaId.Should().Be(east.Id); + eastRow.AreaName.Should().Be("East"); + eastResult.Items.Single(item => item.CompanyName == "East Only").AreaName.Should().Be("East"); + + var eastOrUnassigned = await service.GetCompanyDirectoryPagedAsync( + 1, 50, isActive: true, + areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id }, IncludeUnassigned = true }); + eastOrUnassigned.Items.Select(item => item.CompanyName) + .Should().BeEquivalentTo("East Co", "East Only", "Unassigned Co", "Legacy Co"); + eastOrUnassigned.Items.Single(item => item.CompanyName == "Legacy Co").AreaId.Should().BeNull(); + + var eastAndBoston = await service.GetCompanyDirectoryPagedAsync( + 1, 50, isActive: true, locations: new[] { "Boston, MA" }, + areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id } }); + eastAndBoston.Items.Should().ContainSingle(item => item.CompanyName == "East Co"); + + var matchesNobody = await service.GetCompanyDirectoryPagedAsync( + 1, 50, isActive: true, areaFilter: new VendorAreaFilter()); + matchesNobody.TotalCount.Should().Be(0); + matchesNobody.Items.Should().BeEmpty(); + + var unfiltered = await service.GetCompanyDirectoryPagedAsync(1, 50, isActive: true); + unfiltered.TotalCount.Should().Be(5); + } + + [Fact] + public async Task GetActiveAreasAsync_ExcludesArchivedAreas() + { + await using var context = NewContext(); + context.Areas.AddRange( + new Area { Id = 1, Name = "East", NormalizedName = "east" }, + new Area { Id = 4, Name = "California", NormalizedName = "california", IsActive = false }); + await context.SaveChangesAsync(); + + var areas = await new VendorDataService(context).GetActiveAreasAsync(CancellationToken.None); + + areas.Select(area => area.Name).Should().Equal("East"); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index a8e996d..48351df 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -166,6 +166,7 @@ public class VendorServiceTests It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), + It.IsAny(), token)) .ReturnsAsync((new[] { @@ -212,6 +213,7 @@ public class VendorServiceTests It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), + It.IsAny(), token), Times.Once); } @@ -228,6 +230,7 @@ public class VendorServiceTests It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), + It.IsAny(), It.IsAny())) .ReturnsAsync((new List(), 0)); @@ -756,6 +759,8 @@ public class VendorServiceTests }); data.Setup(x => x.GetActiveTradesAsync(It.IsAny())) .ReturnsAsync(new List()); + data.Setup(x => x.GetActiveAreasAsync(It.IsAny())) + .ReturnsAsync(new List()); var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None); @@ -794,6 +799,8 @@ public class VendorServiceTests }); data.Setup(x => x.GetActiveTradesAsync(It.IsAny())) .ReturnsAsync(new List()); + data.Setup(x => x.GetActiveAreasAsync(It.IsAny())) + .ReturnsAsync(new List()); var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None); @@ -822,6 +829,8 @@ public class VendorServiceTests }); data.Setup(x => x.GetActiveTradesAsync(It.IsAny())) .ReturnsAsync(new List()); + data.Setup(x => x.GetActiveAreasAsync(It.IsAny())) + .ReturnsAsync(new List()); var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None); @@ -849,6 +858,8 @@ public class VendorServiceTests SortOrder = index + 1 }) .ToList()); + data.Setup(x => x.GetActiveAreasAsync(It.IsAny())) + .ReturnsAsync(new List()); var facets = await NewService(data).GetFacetsAsync(true, cts.Token); @@ -898,4 +909,96 @@ public class VendorServiceTests TradeCatalog.CanonicalTrades, opts => opts.WithStrictOrdering()); } + + private static Mock DirectoryDataWithAreas(Action capture) + { + var data = new Mock(); + data.Setup(x => x.GetActiveAreasAsync(It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = 1, Name = "East", NormalizedName = "east" }, + new() { Id = 3, Name = "West", NormalizedName = "west" } + }); + data.Setup(x => x.GetCompanyDirectoryPagedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .Callback?, IReadOnlyCollection?, IReadOnlyCollection?, IReadOnlyCollection?, VendorAreaFilter?, CancellationToken>( + (_, _, _, _, _, _, _, _, filter, _) => capture(filter)) + .ReturnsAsync((new List(), 0)); + return data; + } + + [Fact] + public async Task GetVendorCompanyDirectoryPaged_ResolvesAreaIdsAndUnassignedSentinel() + { + VendorAreaFilter? captured = null; + var data = DirectoryDataWithAreas(filter => captured = filter); + + await NewService(data).GetVendorCompanyDirectoryPagedAsync( + 1, 10, areas: new[] { " 1 ", "__unassigned__", "", "1" }); + + captured.Should().NotBeNull(); + captured!.AreaIds.Should().Equal(1); + captured.IncludeUnassigned.Should().BeTrue(); + } + + [Fact] + public async Task GetVendorCompanyDirectoryPaged_UnknownArchivedOrLabelAreaValuesMatchNobody() + { + VendorAreaFilter? captured = null; + var data = DirectoryDataWithAreas(filter => captured = filter); + + // 4 is not in the active catalogue (unknown or archived); "West" is a label. + var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync( + 1, 10, areas: new[] { "4", "West", "-3" }); + + captured.Should().NotBeNull(); + captured!.AreaIds.Should().BeEmpty(); + captured.IncludeUnassigned.Should().BeFalse(); + result.Items.Should().BeEmpty(); + } + + [Fact] + public async Task GetVendorCompanyDirectoryPaged_BlankAreaValuesApplyNoAreaFilter() + { + VendorAreaFilter? captured = new(); + var data = DirectoryDataWithAreas(filter => captured = filter); + + await NewService(data).GetVendorCompanyDirectoryPagedAsync(1, 10, areas: new[] { " ", "" }); + + captured.Should().BeNull(); + data.Verify(x => x.GetActiveAreasAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetFacetsAsync_AreasComeFromActiveCatalogueOrderedByName() + { + using var cts = new CancellationTokenSource(); + var data = new Mock(); + data.Setup(x => x.GetCompaniesForFacetsAsync(null, It.IsAny())) + .ReturnsAsync(new List()); + data.Setup(x => x.GetActiveTradesAsync(It.IsAny())) + .ReturnsAsync(new List()); + data.Setup(x => x.GetActiveAreasAsync(cts.Token)) + .ReturnsAsync(new List + { + new() { Id = 3, Name = "West", NormalizedName = "west" }, + new() { Id = 1, Name = "East", NormalizedName = "east" }, + new() { Id = 4, Name = "california", NormalizedName = "california" } + }); + + var facets = await NewService(data).GetFacetsAsync(null, cts.Token); + + facets.Areas.Select(area => (area.Id, area.Name)).Should().Equal( + (4, "california"), (1, "East"), (3, "West")); + data.Verify(x => x.GetActiveAreasAsync(cts.Token), Times.Once); + } } diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs new file mode 100644 index 0000000..2d88138 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs @@ -0,0 +1,106 @@ +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// SH-374: GET api/WorkOrder/GetWorkorderById?id= must read the id from the query string, while +/// GET api/WorkOrder/{id} keeps reading it from the route. Both must return the same work order, +/// and an unknown id keeps the existing "ID not found!" response. +/// +public class WorkOrderGetByIdBindingTests +{ + private const int ExistingId = 374; + private const int UnknownId = 999_999; + + private static ControllerActionDescriptor ActionForTemplate(string relativeTemplate) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMvcCore().AddApplicationPart(typeof(WorkOrderController).Assembly); + + using var provider = services.BuildServiceProvider(); + return provider.GetRequiredService().ActionDescriptors.Items + .OfType() + .Where(cad => cad.ControllerTypeInfo == typeof(WorkOrderController)) + .Where(cad => cad.ActionConstraints! + .OfType() + .Any(c => c.HttpMethods.Contains("GET"))) + .Single(cad => string.Equals( + cad.AttributeRouteInfo?.Template?.Trim('/'), + $"api/WorkOrder/{relativeTemplate}", + StringComparison.Ordinal)); + } + + [Theory] + [InlineData("GetWorkorderById", "Query")] + [InlineData("{id:int}", "Path")] + public void Id_binds_from_the_source_its_route_provides(string relativeTemplate, string expectedSource) + { + var action = ActionForTemplate(relativeTemplate); + + var id = action.Parameters.Single(p => p.Name == "id"); + id.BindingInfo.Should().NotBeNull(); + id.BindingInfo!.BindingSource!.Id.Should().Be(expectedSource); + } + + private static (WorkOrderController Controller, WorkOrderDetailReadModel Existing) NewController() + { + var existing = new WorkOrderDetailReadModel { Id = ExistingId, Title = "Leaking roof" }; + var service = new Mock(); + service.Setup(s => s.GetWorkOrderDetailAsync(ExistingId, It.IsAny())).ReturnsAsync(existing); + service.Setup(s => s.GetWorkOrderDetailAsync(UnknownId, It.IsAny())) + .ReturnsAsync((WorkOrderDetailReadModel?)null); + + var controller = new WorkOrderController( + service.Object, + Mock.Of(), + Mock.Of>()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + return (controller, existing); + } + + [Fact] + public async Task Existing_id_returns_the_same_work_order_through_both_routes() + { + var (controller, existing) = NewController(); + + var byQuery = await controller.GetWorkorderById(ExistingId); + var byRoute = await controller.GetWorkorderByRouteId(ExistingId); + + byQuery.Should().BeOfType().Which.Value.Should().BeSameAs(existing); + byRoute.Should().BeOfType().Which.Value.Should().BeSameAs(existing); + } + + [Fact] + public async Task Unknown_id_keeps_the_not_found_response_through_both_routes() + { + var (controller, _) = NewController(); + + foreach (var result in new[] + { + await controller.GetWorkorderById(UnknownId), + await controller.GetWorkorderByRouteId(UnknownId), + }) + { + var body = result.Should().BeOfType().Which.Value + .Should().BeOfType().Subject; + body.Status.Should().Be("Error"); + body.Message.Should().Be("ID not found!"); + } + } +} diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs index 6411337..1bb17e5 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs @@ -17,8 +17,8 @@ namespace Api.SeaHavenIndustries.Tests; /// combination is registered more than once by different actions. /// /// Routes are read from the ASP.NET Core action descriptor provider so the EXACT templates the -/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder, -/// GetWorkorderById) and the two shared controller-level base routes. +/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder) +/// and the two shared controller-level base routes. /// public class WorkOrderRouteContractTests { @@ -39,7 +39,7 @@ public class WorkOrderRouteContractTests /// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122). /// Every action is reachable under both api/WorkOrder and api/workorders; that base-route /// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes - /// come from 50 actions (Editworkorder and GetWorkorderById each bind two routes). + /// come from 51 actions (Editworkorder binds two routes). /// private static readonly HashSet ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal) { diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index 6955cc2..b53c4bf 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers @@ -17,17 +18,159 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("Stats")] - public async Task GetStats(CancellationToken cancellationToken) + public async Task GetStats( + [FromQuery] DashboardStatsQueryDTO query, + CancellationToken cancellationToken) { - var stats = await _dashboardService.GetStatsAsync(cancellationToken); + var stats = await _dashboardService.GetStatsAsync(User, query, cancellationToken); return Ok(new { total = stats.Total, open = stats.Open, notDispatched = stats.NotDispatched, - completed = stats.Completed + completed = stats.Completed, + scheduledTomorrow = stats.ScheduledTomorrow, + pendingUplifts = stats.PendingUplifts, + avetaPending = stats.AvetaPending, + breakdown = stats.Breakdown, + dueCount = stats.DueCount, + completedDueCount = stats.CompletedDueCount, + completionRate = stats.CompletionRate, + averageResolutionDays = stats.AverageResolutionDays, + statusDistribution = stats.StatusDistribution }); } + + [HttpGet("Workload")] + public async Task GetWorkload( + [FromQuery] DashboardStatsQueryDTO query, + [FromQuery] int page = 1, + CancellationToken cancellationToken = default) + { + try + { + return Ok(await _dashboardService.GetWorkloadAsync( + User, query, page, cancellationToken)); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + catch (ArgumentOutOfRangeException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpGet("Performance")] + public async Task GetPerformance( + [FromQuery] DashboardStatsQueryDTO query, + [FromQuery] int page = 1, + CancellationToken cancellationToken = default) + { + try + { + return Ok(await _dashboardService.GetPerformanceAsync( + User, query, page, cancellationToken)); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + catch (ArgumentOutOfRangeException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpGet("Regions")] + public async Task GetRegions( + [FromQuery] DashboardStatsQueryDTO query, + CancellationToken cancellationToken = default) + { + try + { + return Ok(await _dashboardService.GetRegionsAsync(User, query, cancellationToken)); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + } + + [HttpGet("Trend")] + public async Task GetTrend( + [FromQuery] DashboardTrendQueryDTO query, + CancellationToken cancellationToken = default) + { + try + { + var trend = await _dashboardService.GetTrendAsync(User, query, cancellationToken); + + return Ok(new + { + granularity = trend.Granularity, + today = trend.Today, + buckets = trend.Buckets.Select(bucket => new + { + date = bucket.Date, + label = bucket.Label, + total = bucket.Total, + open = bucket.Open, + completed = bucket.Completed, + canceled = bucket.Canceled, + overdue = bucket.Overdue, + isCurrent = bucket.IsCurrent + }) + }); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + catch (ArgumentOutOfRangeException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpGet("VendorInsights")] + public async Task GetVendorInsights(CancellationToken cancellationToken = default) + { + try + { + return Ok(await _dashboardService.GetVendorInsightsAsync(User, cancellationToken)); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + } } } diff --git a/Api.SeaHavenIndustries/Controllers/LocationController.cs b/Api.SeaHavenIndustries/Controllers/LocationController.cs index 9e970a6..a956819 100644 --- a/Api.SeaHavenIndustries/Controllers/LocationController.cs +++ b/Api.SeaHavenIndustries/Controllers/LocationController.cs @@ -36,11 +36,11 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("GetLocationList")] - public async Task GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, CancellationToken cancellationToken = default) + public async Task GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, string? sortBy = null, string? sortDirection = null, CancellationToken cancellationToken = default) { try { - var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken); + var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken, sortBy, sortDirection); var data = pagedResult.Items.Select(l => new { @@ -58,6 +58,8 @@ namespace Api.SeaHavenIndustries.Controllers ContactEmail = l.Email, Status = l.Status, AccountId = l.AccountId, + ClientName = l.AccountName, + AccountName = l.AccountName, Contacts = l.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList() }); diff --git a/Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs b/Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs new file mode 100644 index 0000000..fe66268 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs @@ -0,0 +1,110 @@ +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers +{ + [Authorize] + [ApiController] + [Route("api/services")] + public class ServicesRegistryController : Controller + { + private readonly IServicesRegistryService _servicesRegistryService; + + public ServicesRegistryController(IServicesRegistryService servicesRegistryService) + { + _servicesRegistryService = servicesRegistryService; + } + + [HttpGet] + public async Task GetAll([FromQuery] bool? isActive, [FromQuery] WorkOrderType? workOrderType, CancellationToken cancellationToken) + { + try + { + var services = await _servicesRegistryService.GetAllAsync(isActive, workOrderType, cancellationToken); + return Ok(services); + } + catch (ServicesRegistryValidationException ex) + { + return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpGet("{id}")] + public async Task GetById(int id, CancellationToken cancellationToken) + { + var service = await _servicesRegistryService.GetByIdAsync(id, cancellationToken); + if (service == null) + return NotFound(new ServicesRegistryValidationErrorDto { Code = "NotFound", Message = "Service not found." }); + + return Ok(service); + } + + [HttpPost] + public async Task Create([FromBody] ServiceInput input, CancellationToken cancellationToken) + { + try + { + var created = await _servicesRegistryService.CreateAsync(User, input, cancellationToken); + return StatusCode(StatusCodes.Status201Created, created); + } + catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden") + { + return MapForbidden(ex); + } + catch (ServicesRegistryValidationException ex) + { + return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpPut("{id}")] + public async Task Update(int id, [FromBody] ServiceInput input, CancellationToken cancellationToken) + { + try + { + var updated = await _servicesRegistryService.UpdateAsync(User, id, input, cancellationToken); + return Ok(updated); + } + catch (ServicesRegistryValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden") + { + return MapForbidden(ex); + } + catch (ServicesRegistryValidationException ex) + { + return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpPost("{id}/deactivate")] + public async Task Deactivate(int id, CancellationToken cancellationToken) + { + try + { + await _servicesRegistryService.DeactivateAsync(User, id, cancellationToken); + return Ok(new { message = "Service deactivated" }); + } + catch (ServicesRegistryValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden") + { + return MapForbidden(ex); + } + } + + private IActionResult MapForbidden(ServicesRegistryValidationException ex) + { + return StatusCode(StatusCodes.Status403Forbidden, + new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs b/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs new file mode 100644 index 0000000..d09c6bd --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs @@ -0,0 +1,36 @@ +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); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/TeamPermissionController.cs b/Api.SeaHavenIndustries/Controllers/TeamPermissionController.cs new file mode 100644 index 0000000..72704cc --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/TeamPermissionController.cs @@ -0,0 +1,65 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers; + +[Authorize] +[ApiController] +[Route("api/User/{userId}/Permissions")] +public sealed class TeamPermissionController : ControllerBase +{ + private readonly ITeamPermissionService _permissionService; + + public TeamPermissionController(ITeamPermissionService permissionService) + { + _permissionService = permissionService; + } + + [HttpGet] + public async Task GetProfile( + string userId, + CancellationToken cancellationToken) + { + var result = await _permissionService.GetProfileAsync(userId, User, cancellationToken); + return ToActionResult(result); + } + + [HttpPut("{permissionKey}")] + public async Task SetOverride( + string userId, + string permissionKey, + [FromBody] SetTeamPermissionOverrideDTO request, + CancellationToken cancellationToken) + { + if (!Enum.IsDefined(request.State)) + return BadRequest(new Response { Status = "Error", Message = "Invalid permission state." }); + + var result = await _permissionService.SetOverrideAsync( + userId, + permissionKey, + request.State, + User, + cancellationToken); + return ToActionResult(result); + } + + private static IActionResult ToActionResult( + TeamPermissionResult result) + { + return result.Status switch + { + TeamPermissionResultStatus.Success => new OkObjectResult(result.Value), + TeamPermissionResultStatus.Forbidden => new ForbidResult(), + TeamPermissionResultStatus.NotFound => new NotFoundObjectResult( + new Response { Status = "Error", Message = "User not found." }), + TeamPermissionResultStatus.InvalidPermissionKey => new BadRequestObjectResult( + new Response { Status = "Error", Message = "Unknown permission key." }), + _ => new BadRequestObjectResult( + new Response { Status = "Error", Message = "Unable to update permissions." }) + }; + } +} diff --git a/Api.SeaHavenIndustries/Controllers/UpliftController.cs b/Api.SeaHavenIndustries/Controllers/UpliftController.cs index cc2d9ca..fcba99f 100644 --- a/Api.SeaHavenIndustries/Controllers/UpliftController.cs +++ b/Api.SeaHavenIndustries/Controllers/UpliftController.cs @@ -127,6 +127,35 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpPost("{id:int}/revoke")] + public async Task Revoke( + int id, + [FromBody] DecisionRequest? body, + CancellationToken cancellationToken = default) + { + try + { + var result = await _upliftService.RevokeAsync( + User, + id, + body?.Note ?? string.Empty, + cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Uplift request not found") }); + } + catch (UpliftForbiddenException ex) + { + return StatusCode(403, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to revoke this uplift") }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "This uplift request cannot be revoked") }); + } + } + [HttpGet("can-approve")] public IActionResult CanApprove([FromQuery] int tier) { diff --git a/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs b/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs index fa5e4fa..1930fe9 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs @@ -2,6 +2,7 @@ using Api.SeaHavenIndustries.Helper; using Data.SeaHavenIndustries; using FluentValidation; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -70,7 +71,7 @@ namespace Api.SeaHavenIndustries.Controllers try { - var roster = await _rosterService.CreateRosterAsync(model, userId, cancellationToken); + var roster = await _rosterService.CreateRosterAsync(model, userId, User, cancellationToken); return Ok(roster); } catch (ValidationException vex) @@ -82,6 +83,12 @@ namespace Api.SeaHavenIndustries.Controllers { return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); } + catch (VendorAreaAssignmentForbiddenException) + { + return StatusCode( + StatusCodes.Status403Forbidden, + new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." }); + } catch (VendorRosterDuplicateNameException dup) { return Conflict(new @@ -109,7 +116,7 @@ namespace Api.SeaHavenIndustries.Controllers try { - var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, cancellationToken); + var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, User, cancellationToken); return Ok(roster); } catch (ValidationException vex) @@ -121,6 +128,12 @@ namespace Api.SeaHavenIndustries.Controllers { return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); } + catch (VendorAreaAssignmentForbiddenException) + { + return StatusCode( + StatusCodes.Status403Forbidden, + new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." }); + } catch (KeyNotFoundException) { return NotFound(new Response { Status = "Error", Message = "Vendor company not found" }); @@ -164,7 +177,7 @@ namespace Api.SeaHavenIndustries.Controllers try { - var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, cancellationToken); + var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, User, cancellationToken); return Ok(roster); } catch (ValidationException vex) @@ -176,6 +189,12 @@ namespace Api.SeaHavenIndustries.Controllers { return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); } + catch (VendorAreaAssignmentForbiddenException) + { + return StatusCode( + StatusCodes.Status403Forbidden, + new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." }); + } catch (KeyNotFoundException) { return NotFound(new Response { Status = "Error", Message = "Vendor company not found" }); diff --git a/Api.SeaHavenIndustries/Controllers/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index fc42b88..2db1eba 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -97,6 +97,7 @@ namespace Api.SeaHavenIndustries.Controllers [FromQuery] string[]? trades = null, [FromQuery] string[]? locations = null, [FromQuery] string[]? jobBuckets = null, + [FromQuery] string[]? areas = null, CancellationToken cancellationToken = default) { var pagedResult = await _vendorService.GetVendorCompanyDirectoryPagedAsync( @@ -108,6 +109,7 @@ namespace Api.SeaHavenIndustries.Controllers trades, locations, jobBuckets, + areas, cancellationToken); var data = pagedResult.Items.Select(v => new @@ -129,6 +131,8 @@ namespace Api.SeaHavenIndustries.Controllers v.Notes, v.IsActive, v.TotalJobs, + v.AreaId, + v.AreaName, Technicians = v.Technicians.Select(t => new { t.Id, diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index c7928de..9c73034 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -208,9 +208,15 @@ namespace Api.SeaHavenIndustries.Controllers } } - [HttpGet("{id:int}")] + // SH-374: two actions, one per route, so each binds id from the source its route provides. + // A single action carrying both routes inferred id as [FromRoute] and the query route got 0. [HttpGet("GetWorkorderById")] - public async Task GetWorkorderById(int id) + public Task GetWorkorderById([FromQuery] int id) => GetWorkorderDetail(id); + + [HttpGet("{id:int}")] + public Task GetWorkorderByRouteId([FromRoute] int id) => GetWorkorderDetail(id); + + private async Task GetWorkorderDetail(int id) { try { 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 a5256dc..fe944ee 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -80,6 +80,12 @@ namespace Data.SeaHavenIndustries .HasIndex(w => w.InternalWONumber) .HasFilter("[istemplate] = 0"); + builder.Entity() + .HasOne(w => w.ServiceDefinition) + .WithMany() + .HasForeignKey(w => w.ServiceId) + .OnDelete(DeleteBehavior.Restrict); + builder.Entity() .HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate }) .HasFilter("[istemplate] = 0"); @@ -170,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(); @@ -206,6 +218,118 @@ namespace Data.SeaHavenIndustries builder.Entity() .HasIndex(t => t.NormalizedName) .IsUnique(); + + // Bounded lengths keep the unique NormalizedName key SQL + // Server-indexable (nvarchar(max) is not). + builder.Entity() + .Property(s => s.Name) + .HasMaxLength(200); + + builder.Entity() + .Property(s => s.NormalizedName) + .HasMaxLength(200); + + builder.Entity() + .HasIndex(s => s.NormalizedName) + .IsUnique(); + + builder.Entity() + .Property(s => s.Trade) + .HasMaxLength(64); + + builder.Entity() + .HasIndex(s => s.Trade); + + builder.Entity() + .Property(s => s.IconKey) + .HasMaxLength(64); + + builder.Entity() + .HasIndex(s => s.IsActive); + + builder.Entity() + .HasOne(s => s.CompletionDocTemplate) + .WithMany() + .HasForeignKey(s => s.CompletionDocTemplateId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasIndex(s => s.CompletionDocTemplateId); + + builder.Entity() + .HasIndex(t => new { t.ServiceId, t.WorkOrderType }) + .IsUnique(); + + builder.Entity() + .HasOne(t => t.Service) + .WithMany(s => s.SupportedWorkOrderTypes) + .HasForeignKey(t => t.ServiceId) + .OnDelete(DeleteBehavior.Restrict); + + // SH-278 Area catalogue: bounded names so the unique key is indexable. + builder.Entity() + .Property(a => a.Name) + .HasMaxLength(128); + + builder.Entity() + .Property(a => a.NormalizedName) + .HasMaxLength(128); + + builder.Entity() + .HasIndex(a => a.NormalizedName) + .IsUnique(); + + builder.Entity() + .HasOne(c => c.Area) + .WithMany() + .HasForeignKey(c => c.AreaId) + .OnDelete(DeleteBehavior.Restrict); + + // Per-person team permission overrides. Composite primary key + // (UserId + PermissionKey) plus an explicitly named unique composite + // index; missing rows behave as Unset. + builder.Entity(entity => + { + entity.HasKey(o => new { o.UserId, o.PermissionKey }); + + entity.Property(o => o.UserId) + .HasMaxLength(450); + + entity.Property(o => o.PermissionKey) + .HasMaxLength(64); + + entity.Property(o => o.State) + .IsRequired(); + + entity.HasOne(o => o.User) + .WithMany() + .HasForeignKey(o => o.UserId) + .OnDelete(DeleteBehavior.Restrict); + + entity.HasIndex(o => new { o.UserId, o.PermissionKey }) + .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; } @@ -228,6 +352,7 @@ namespace Data.SeaHavenIndustries public DbSet WorkOrderExternalReceipts { get; set; } public DbSet DropdownOptions { get; set; } public DbSet Trades { get; set; } + public DbSet Areas { get; set; } public DbSet Vendors { get; set; } public DbSet VendorCompanies { get; set; } public DbSet VendorAuditLogs { get; set; } @@ -241,6 +366,8 @@ namespace Data.SeaHavenIndustries public DbSet DispatchUpliftRequests { get; set; } public DbSet TaskListTemplates { get; set; } public DbSet TaskListTemplateItems { get; set; } + public DbSet Services { get; set; } + public DbSet ServiceWorkOrderTypes { get; set; } // New DbSets for added entities public DbSet Accounts { get; set; } @@ -259,6 +386,8 @@ namespace Data.SeaHavenIndustries public DbSet Departments { get; set; } public DbSet JobTitles { get; set; } public DbSet Regions { get; set; } + public DbSet UserPermissionOverrides { get; set; } + public DbSet UserServiceAreas { get; set; } public override int SaveChanges() { @@ -338,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