mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
The parity test now also asserts which rows the drill-down lists: legacy open rows (status in Status or in LegacyStatus, or none) are listed and legacy closed, cancelled or assigned rows are not. Drops ticket keys from comments.
249 lines
10 KiB
C#
249 lines
10 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>
|
|
/// 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,
|
|
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<IEnumerable<int>> Ids(LifecycleStatus status)
|
|
=> (await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
|
|
{
|
|
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
|
|
DateFrom = From,
|
|
DateTo = To,
|
|
Statuses = new List<LifecycleStatus> { 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<string> { "__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 });
|
|
}
|
|
}
|