shoc-backend/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs
Alexandre Brandizzi 564bd70301 fix(dashboard): scope Trend by picker and admit Scheduler to dispatcher picker
ResolveDispatcherScope now admits the Scheduler role, which owns the
viewAllDispatchersOnDashboard permission, so it can load Stats, Workload,
Performance, Regions and Trend instead of being denied.

GetTrendAsync now resolves scope via the shared ResolveDispatcherScope so a
picker DispatcherId selection scopes Trend consistently with the other
endpoints and unauthorized roles fail closed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-17 03:37:36 -03:00

1015 lines
41 KiB
C#

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<ApplicationDbContext>()
.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<Dispatch>().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<DispatchUpliftRequest>().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<WorkOrderBoardValidationException>();
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<string> { UserId = "dispatcher-1", RoleId = "dispatcher-role" },
new IdentityUserRole<string> { 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<string>
{
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<WorkOrderBoardValidationException>();
statsException.Which.Code.Should().Be("Forbidden");
var workloadAct = () => service.GetWorkloadAsync(
user, new DashboardStatsQueryDTO(), 1, CancellationToken.None);
var workloadException = await workloadAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
workloadException.Which.Code.Should().Be("Forbidden");
var performanceAct = () => service.GetPerformanceAsync(
user, new DashboardStatsQueryDTO(), 1, CancellationToken.None);
var performanceException = await performanceAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
performanceException.Which.Code.Should().Be("Forbidden");
var regionsAct = () => service.GetRegionsAsync(
user, new DashboardStatsQueryDTO(), CancellationToken.None);
var regionsException = await regionsAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
regionsException.Which.Code.Should().Be("Forbidden");
var trendAct = () => service.GetTrendAsync(
user, new DashboardTrendQueryDTO(), CancellationToken.None);
var trendException = await trendAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
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<string> { UserId = "dispatcher-1", RoleId = "dispatcher-role" },
new IdentityUserRole<string> { 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<string>
{
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);
}
}