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; namespace Api.SeaHavenIndustries.Tests; public class DashboardServiceTests { private static ApplicationDbContext NewContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; return new ApplicationDbContext(options); } 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 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( 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( 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(0); stats.Breakdown.Other.Should().Be(0); stats.Breakdown.PM.Should().Be(2); stats.Breakdown.Emergency.Should().Be(1); 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); } }