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.Helpers; using SeaHaven.Services.Implementation; using Xunit; namespace Api.SeaHavenIndustries.Tests; /// /// SH-392: the Dashboard "Unassigned" count is open work orders with no /// dispatcher in the selected period, and it equals the Work Orders list the /// tile drills into (GET /board/search, Dispatcher = Unassigned). /// public class DashboardUnassignedTests { private static readonly DateOnly From = new(2026, 9, 21); private static readonly DateOnly To = new(2026, 9, 25); private static readonly List OpenStatuses = Enum.GetValues() .Where(s => s != LifecycleStatus.Completed && s != LifecycleStatus.Canceled) .ToList(); private static ApplicationDbContext NewContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; return new ApplicationDbContext(options); } private static WorkOrderAccountResolver Resolver(ApplicationDbContext ctx) => new(new AccountDataService(ctx), new LocationDataService(ctx)); private static DashboardService NewDashboard(ApplicationDbContext ctx) => new(new DashboardDataService(ctx), Resolver(ctx)); private static WorkOrderAdvancedSearchService NewSearch(ApplicationDbContext ctx) => new(new WorkOrderAdvancedSearchDataService(ctx), Resolver(ctx)); private static ClaimsPrincipal AccountUser(int accountId, string role = "Admin", string userId = "admin-1") => new(new ClaimsIdentity(new[] { new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()), new Claim(ClaimTypes.NameIdentifier, userId), new Claim(ClaimTypes.Role, role) }, "test")); private static WorkOrder Wo( int id, DateTime? scheduled, string? assignTo = null, LifecycleStatus status = LifecycleStatus.Scheduled, DateOnly? targetWeek = null, int accountId = 1) => new() { Id = id, AccountId = accountId, InternalWONumber = $"2000000{id:0000}", AssignTo = assignTo, ScheduledDate = scheduled, ScheduleWeekOnly = targetWeek.HasValue, TargetWeek = targetWeek, LifecycleStatus = status, istemplate = false }; private static void Seed(ApplicationDbContext ctx) { ctx.workOrders.AddRange( Wo(1, new DateTime(2026, 9, 22)), // counted Wo(2, new DateTime(2026, 9, 23), assignTo: ""), // counted: blank assignee Wo(3, null, targetWeek: new DateOnly(2026, 9, 21)), // counted: week-only in range Wo(4, new DateTime(2026, 9, 24), assignTo: "disp-1"), // assigned Wo(5, new DateTime(2026, 9, 24), status: LifecycleStatus.Completed), Wo(6, new DateTime(2026, 9, 24), status: LifecycleStatus.Canceled), Wo(7, new DateTime(2026, 8, 4)), // out of range Wo(8, null, status: LifecycleStatus.Incomplete), // undated Wo(9, new DateTime(2026, 9, 22), accountId: 2), // other tenant Wo(10, null, status: LifecycleStatus.Incomplete, accountId: 2)); // other tenant, undated ctx.SaveChanges(); } [Fact] public async Task GetStatsAsync_Range_CountsOpenUnassignedScheduledInPeriod() { using var ctx = NewContext(); Seed(ctx); var stats = await NewDashboard(ctx).GetStatsAsync( AccountUser(1), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None); stats.Unassigned.Should().Be(3); } [Fact] public async Task GetStatsAsync_AllTime_CountsEveryOpenUnassignedIncludingUndated() { using var ctx = NewContext(); Seed(ctx); var stats = await NewDashboard(ctx).GetStatsAsync( AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); // 1, 2, 3, 7 (out of any week but open) and 8 (undated). stats.Unassigned.Should().Be(5); } [Fact] public async Task GetStatsAsync_Unassigned_NeverCountsAnotherTenantsWorkOrders() { using var ctx = NewContext(); Seed(ctx); var accountTwo = await NewDashboard(ctx).GetStatsAsync( AccountUser(2), new DashboardStatsQueryDTO(), CancellationToken.None); var accountTwoInRange = await NewDashboard(ctx).GetStatsAsync( AccountUser(2), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None); accountTwo.Unassigned.Should().Be(2); accountTwoInRange.Unassigned.Should().Be(1); } [Fact] public async Task GetStatsAsync_DispatcherScope_HasNoUnassignedWork() { using var ctx = NewContext(); Seed(ctx); var dispatcher = await NewDashboard(ctx).GetStatsAsync( AccountUser(1, role: "Dispatcher", userId: "disp-1"), new DashboardStatsQueryDTO(), CancellationToken.None); var adminPickingDispatcher = await NewDashboard(ctx).GetStatsAsync( AccountUser(1), new DashboardStatsQueryDTO { DispatcherId = "disp-1" }, CancellationToken.None); dispatcher.Unassigned.Should().Be(0); adminPickingDispatcher.Unassigned.Should().Be(0); } [Theory] [InlineData(true)] [InlineData(false)] public async Task GetStatsAsync_UnassignedMatchesTheDrillDownList(bool withRange) { using var ctx = NewContext(); Seed(ctx); var user = AccountUser(1); var stats = await NewDashboard(ctx).GetStatsAsync( user, withRange ? new DashboardStatsQueryDTO { DateFrom = From, DateTo = To } : new DashboardStatsQueryDTO(), CancellationToken.None); // What the Work Orders board sends for the tile's drill-down link. var list = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto { DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, DateFrom = withRange ? From : new DateOnly(2000, 1, 1), DateTo = withRange ? To : new DateOnly(2099, 12, 31), IncludeDateless = !withRange, Dispatchers = new List { "__unassigned__" }, Statuses = OpenStatuses, PageSize = 200 }, user, "admin-1"); stats.Unassigned.Should().Be(list.TotalCount); } }