using System.Security.Claims; using Data.SeaHavenIndustries; using FluentAssertions; 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); } }