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_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(1); result.Items.Should().ContainSingle(); result.Items[0].DispatcherName.Should().Be("Ada One"); result.Items[0].TotalCount.Should().Be(2); result.Items[0].OpenCount.Should().Be(1); 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" }); 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 = 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"); result.Items.Select(row => row.WorkOrderCount).Should().Equal(1, 0, 0, 1, 2); } [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); } }