shoc-backend/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs
Alexandre Brandizzi 7297e30212 fix(dashboard): count All time over the same window the board lists (SH-392)
The Unassigned drill-down opens the board with no range, which searches
2000-01-01..2099-12-31 plus undated work. The All time count had no
bounds, so a work order dated outside that window was counted but not
listed. The count now uses the same window.
2026-09-24 21:45:06 -03:00

179 lines
7 KiB
C#

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;
/// <summary>
/// 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).
/// </summary>
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<LifecycleStatus> OpenStatuses = Enum.GetValues<LifecycleStatus>()
.Where(s => s != LifecycleStatus.Completed && s != LifecycleStatus.Canceled)
.ToList();
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.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);
// 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<string> { "__unassigned__" },
Statuses = OpenStatuses,
PageSize = 200
}, user, "admin-1");
stats.Unassigned.Should().Be(list.TotalCount);
}
}