mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-06 14:42:09 +00:00
GET /dashboard/stats now returns unassigned: open (not Completed or Canceled) work orders with no dispatcher in the selected period, within the caller's server-derived account scope. It uses the same filters as the Unassigned board search, so the Dashboard number equals the list it drills into. A dispatcher-scoped Dashboard has no unassigned work and returns 0.
176 lines
6.8 KiB
C#
176 lines
6.8 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);
|
|
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);
|
|
}
|
|
}
|