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; /// /// 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, string? legacyStatus = null, string? statusText = null) => new() { Id = id, AccountId = accountId, InternalWONumber = $"2000000{id:0000}", AssignTo = assignTo, ScheduledDate = scheduled, ScheduleWeekOnly = targetWeek.HasValue, TargetWeek = targetWeek, LifecycleStatus = status, LegacyStatus = legacyStatus, Status = statusText, istemplate = false }; // Rows written by legacy create paths, which set Status but no LifecycleStatus. private static void SeedLegacy(ApplicationDbContext ctx) { var inRange = new DateTime(2026, 9, 22); ctx.workOrders.AddRange( Wo(13, inRange, status: null, statusText: "Open"), // counted Wo(14, inRange, status: null, statusText: "On Hold"), // counted: Pending Wo(15, inRange, status: null), // counted: no status at all Wo(16, inRange, status: null, legacyStatus: "Done", statusText: "Open"), // closed: LegacyStatus wins Wo(17, inRange, status: null, statusText: " Cancelled "), // canceled Wo(18, inRange, assignTo: "disp-1", status: null, statusText: "Open"), // assigned Wo(19, inRange, status: null, legacyStatus: "Open", statusText: "Done")); // counted: LegacyStatus wins ctx.SaveChanges(); } 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_CountsOpenLegacyRowsWithNoLifecycleStatus() { using var ctx = NewContext(); Seed(ctx); SeedLegacy(ctx); var inRange = await NewDashboard(ctx).GetStatsAsync( AccountUser(1), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None); var allTime = await NewDashboard(ctx).GetStatsAsync( AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); // 1, 2, 3 plus legacy 13, 14, 15 and 19. inRange.Unassigned.Should().Be(7); allTime.Unassigned.Should().Be(9); } [Fact] public async Task BoardSearch_StatusFilter_MatchesLegacyRowsByTheirLegacyStatus() { using var ctx = NewContext(); var inRange = new DateTime(2026, 9, 22); ctx.workOrders.AddRange( Wo(1, inRange), Wo(2, inRange, status: null, statusText: "scheduled"), Wo(3, inRange, status: null, statusText: "Open"), Wo(4, inRange, status: null, statusText: "Legacy Mystery"), Wo(5, inRange, status: null)); ctx.SaveChanges(); var user = AccountUser(1); async Task> Ids(LifecycleStatus status) => (await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto { DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, DateFrom = From, DateTo = To, Statuses = new List { status }, PageSize = 200 }, user, "admin-1")).Items.Select(row => row.Id); (await Ids(LifecycleStatus.Scheduled)).Should().BeEquivalentTo(new[] { 1, 2 }); // Unknown or missing legacy text reads as Incomplete, as the Phase0 backfill set it. (await Ids(LifecycleStatus.Incomplete)).Should().BeEquivalentTo(new[] { 3, 4, 5 }); (await Ids(LifecycleStatus.Completed)).Should().BeEmpty(); } [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); SeedLegacy(ctx); // Dated outside the board's all-weeks window: neither side may count them. ctx.workOrders.AddRange(Wo(11, new DateTime(1999, 12, 31)), Wo(12, new DateTime(2100, 1, 1))); ctx.SaveChanges(); 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); var listed = list.Items.Select(row => row.Id).ToList(); listed.Should().Contain(new[] { 13, 14, 15, 19 }); listed.Should().NotContain(new[] { 16, 17, 18 }); } }