mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
Overdue (8) is a dispatcher-assigned type, separate from the derived past-due overlay. It takes no severity, resolves services and completion-doc templates from the PM catalog, and filters as its own type. The past-due flag now narrows a type filter instead of widening it, and the dashboard breakdown partitions by stored type.
1015 lines
41 KiB
C#
1015 lines
41 KiB
C#
using System.Security.Claims;
|
|
using Data.SeaHavenIndustries;
|
|
using FluentAssertions;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using SeaHaven.DataServices.Implementation;
|
|
using SeaHaven.Services.Exceptions;
|
|
using SeaHaven.Services.DTOs;
|
|
using SeaHaven.Services.Helpers;
|
|
using SeaHaven.Services.Implementation;
|
|
using Xunit;
|
|
|
|
namespace Api.SeaHavenIndustries.Tests;
|
|
|
|
public class DashboardServiceTests
|
|
{
|
|
private static ApplicationDbContext NewContext()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
|
.Options;
|
|
return new ApplicationDbContext(options);
|
|
}
|
|
|
|
private static DashboardService NewService(ApplicationDbContext ctx)
|
|
{
|
|
var resolver = new WorkOrderAccountResolver(
|
|
new AccountDataService(ctx),
|
|
new LocationDataService(ctx));
|
|
return new DashboardService(new DashboardDataService(ctx), resolver);
|
|
}
|
|
|
|
private static ClaimsPrincipal AccountUser(
|
|
int accountId,
|
|
string userId = "dispatcher-1",
|
|
string role = "Admin")
|
|
{
|
|
var claims = new[]
|
|
{
|
|
new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
|
|
new Claim(ClaimTypes.NameIdentifier, userId),
|
|
new Claim(ClaimTypes.Role, role)
|
|
};
|
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
|
}
|
|
|
|
private static ClaimsPrincipal OrgWideUser()
|
|
{
|
|
var claims = new[]
|
|
{
|
|
new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll),
|
|
new Claim(ClaimTypes.Role, "Admin")
|
|
};
|
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetStatsAsync_CountsByStatusExcludingTemplates()
|
|
{
|
|
using var ctx = NewContext();
|
|
ctx.workOrders.AddRange(
|
|
new WorkOrder { AccountId = 1, Status = "Open", istemplate = false },
|
|
new WorkOrder { AccountId = 1, Status = "Open", istemplate = false, AssignTo = "u1" },
|
|
new WorkOrder { AccountId = 1, Status = "In Progress" },
|
|
new WorkOrder { AccountId = 1, Status = "On Hold" },
|
|
new WorkOrder { AccountId = 1, Status = "Done" },
|
|
new WorkOrder { AccountId = 1, Status = "Done" },
|
|
new WorkOrder { AccountId = 1, Status = "Open", istemplate = true },
|
|
new WorkOrder { AccountId = 1, Status = "Cancelled" },
|
|
new WorkOrder { AccountId = 2, Status = "Open" },
|
|
new WorkOrder { Status = "Open" }
|
|
);
|
|
ctx.SaveChanges();
|
|
|
|
var stats = await NewService(ctx).GetStatsAsync(
|
|
AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None);
|
|
|
|
stats.Total.Should().Be(7);
|
|
stats.Open.Should().Be(4);
|
|
stats.NotDispatched.Should().Be(1);
|
|
stats.Completed.Should().Be(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetStatsAsync_OrgWideUserSeesAllNonTemplateOrders()
|
|
{
|
|
using var ctx = NewContext();
|
|
ctx.workOrders.AddRange(
|
|
new WorkOrder { AccountId = 1, Status = "Open" },
|
|
new WorkOrder { AccountId = 2, Status = "Done" },
|
|
new WorkOrder { Status = "Open", istemplate = true });
|
|
ctx.SaveChanges();
|
|
|
|
var stats = await NewService(ctx).GetStatsAsync(
|
|
OrgWideUser(), new DashboardStatsQueryDTO(), CancellationToken.None);
|
|
|
|
stats.Total.Should().Be(2);
|
|
stats.Open.Should().Be(1);
|
|
stats.Completed.Should().Be(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetStatsAsync_ExposesKpiCounts_PendingUpliftsCountsPendingStatusOnly()
|
|
{
|
|
using var ctx = NewContext();
|
|
var today = DashboardBusinessTime.Today();
|
|
var tomorrow = today.AddDays(1);
|
|
|
|
// Scheduled-tomorrow tile: one account-1 order scheduled for tomorrow.
|
|
ctx.workOrders.Add(new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
|
ScheduledDate = tomorrow.ToDateTime(TimeOnly.MinValue)
|
|
});
|
|
|
|
// Aveta-pending tile: Aveta-required order in the today/tomorrow window
|
|
// with no Aveta attachment. Its Dispatch anchors the uplift requests below.
|
|
var avetaOrder = new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
AvetaRequired = true,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
|
ScheduledDate = today.ToDateTime(TimeOnly.MinValue)
|
|
};
|
|
ctx.workOrders.Add(avetaOrder);
|
|
ctx.SaveChanges();
|
|
|
|
var dispatch = new Dispatch { WorkOrderId = avetaOrder.Id, IsDeleted = false };
|
|
ctx.Set<Dispatch>().Add(dispatch);
|
|
ctx.SaveChanges();
|
|
|
|
// Pending-uplifts tile: only Status == "Pending" is an outstanding uplift
|
|
// awaiting an approval decision. This test pins the behaviour the code
|
|
// ships today: a "ChangesRequested" request has been sent back to the
|
|
// vendor, so it is NOT counted. Whether pendingUplifts should also include
|
|
// ChangesRequested is an open product call, not a settled review decision.
|
|
ctx.Set<DispatchUpliftRequest>().AddRange(
|
|
new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "Pending", IsDeleted = false },
|
|
new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "ChangesRequested", IsDeleted = false });
|
|
ctx.SaveChanges();
|
|
|
|
var stats = await NewService(ctx).GetStatsAsync(
|
|
AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None);
|
|
|
|
stats.ScheduledTomorrow.Should().Be(1);
|
|
stats.AvetaPending.Should().Be(1);
|
|
stats.PendingUplifts.Should().Be(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetStatsAsync_MissingScopeFailsClosed()
|
|
{
|
|
using var ctx = NewContext();
|
|
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(ClaimTypes.Role, "Dispatcher")
|
|
}, "test"));
|
|
|
|
var act = () => NewService(ctx).GetStatsAsync(
|
|
user, new DashboardStatsQueryDTO(), CancellationToken.None);
|
|
|
|
var exception = await act.Should().ThrowAsync<WorkOrderBoardValidationException>();
|
|
|
|
exception.Which.Code.Should().Be("Forbidden");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetStatsAsync_UsesDashboardMetricRulesForDateRange()
|
|
{
|
|
using var ctx = NewContext();
|
|
var today = DashboardBusinessTime.Today();
|
|
ctx.workOrders.AddRange(
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.PM,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
|
ScheduledDate = today.ToDateTime(TimeOnly.MinValue),
|
|
OriginalDate = today.AddDays(-6)
|
|
},
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.Emergency,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.InProgress,
|
|
ScheduledDate = today.AddDays(-3).ToDateTime(TimeOnly.MinValue)
|
|
},
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.Reactive,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
|
|
ScheduledDate = today.ToDateTime(TimeOnly.MinValue),
|
|
OriginalDate = today.AddDays(-15),
|
|
CompletedDate = today.AddDays(-12).ToDateTime(TimeOnly.MinValue)
|
|
},
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.PM,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Incomplete,
|
|
ScheduledDate = today.AddDays(4).ToDateTime(TimeOnly.MinValue)
|
|
});
|
|
ctx.SaveChanges();
|
|
|
|
var query = new DashboardStatsQueryDTO
|
|
{
|
|
DateFrom = today.AddDays(-10),
|
|
DateTo = today.AddDays(10)
|
|
};
|
|
var stats = await NewService(ctx).GetStatsAsync(
|
|
AccountUser(1), query, CancellationToken.None);
|
|
|
|
stats.Total.Should().Be(4);
|
|
stats.Breakdown.Overdue.Should().Be(0);
|
|
stats.Breakdown.Other.Should().Be(0);
|
|
stats.Breakdown.PM.Should().Be(2);
|
|
stats.Breakdown.Emergency.Should().Be(1);
|
|
stats.Breakdown.Reactive.Should().Be(1);
|
|
(stats.Breakdown.PM + stats.Breakdown.Emergency + stats.Breakdown.Reactive
|
|
+ stats.Breakdown.Overdue + stats.Breakdown.Other).Should().Be(stats.Total);
|
|
stats.DueCount.Should().Be(3);
|
|
stats.CompletedDueCount.Should().Be(1);
|
|
stats.CompletionRate.Should().Be(33.33m);
|
|
stats.AverageResolutionDays.Should().Be(3m);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetStatsAsync_DispatcherOnlySeesAssignedOrders()
|
|
{
|
|
using var ctx = NewContext();
|
|
ctx.workOrders.AddRange(
|
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
|
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
|
|
ctx.SaveChanges();
|
|
|
|
var stats = await NewService(ctx).GetStatsAsync(
|
|
AccountUser(1, role: "Dispatcher"), new DashboardStatsQueryDTO(), CancellationToken.None);
|
|
|
|
stats.Total.Should().Be(1);
|
|
stats.Open.Should().Be(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetWorkloadAsync_UsesRoleScopeAndExcludesTerminalOrdersFromOpenCount()
|
|
{
|
|
using var ctx = NewContext();
|
|
ctx.Roles.Add(new IdentityRole
|
|
{
|
|
Id = "dispatcher-role",
|
|
Name = "Dispatcher",
|
|
NormalizedName = "DISPATCHER"
|
|
});
|
|
ctx.Users.AddRange(
|
|
new ApplicationUser { Id = "dispatcher-1", FirstName = "Ada", LastName = "One" },
|
|
new ApplicationUser { Id = "dispatcher-2", FirstName = "Ben", LastName = "Two" });
|
|
ctx.UserRoles.AddRange(
|
|
new IdentityUserRole<string> { UserId = "dispatcher-1", RoleId = "dispatcher-role" },
|
|
new IdentityUserRole<string> { UserId = "dispatcher-2", RoleId = "dispatcher-role" });
|
|
ctx.workOrders.AddRange(
|
|
new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", Status = "Open" },
|
|
new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed },
|
|
new WorkOrder { AccountId = 1, AssignTo = "dispatcher-2", Status = "Open" });
|
|
ctx.SaveChanges();
|
|
|
|
var result = await NewService(ctx).GetWorkloadAsync(
|
|
AccountUser(1, "dispatcher-1", "Dispatcher"),
|
|
new DashboardStatsQueryDTO(),
|
|
1,
|
|
CancellationToken.None);
|
|
|
|
result.TotalDispatchers.Should().Be(2);
|
|
result.Items.Select(item => item.DispatcherName)
|
|
.Should().ContainInOrder("Ada One", "Ben Two");
|
|
result.Items[0].TotalCount.Should().Be(2);
|
|
result.Items[0].OpenCount.Should().Be(1);
|
|
result.Items[1].TotalCount.Should().Be(0);
|
|
result.Items[1].OpenCount.Should().Be(0);
|
|
result.PageSize.Should().Be(10);
|
|
|
|
var adminResult = await NewService(ctx).GetWorkloadAsync(
|
|
AccountUser(1),
|
|
new DashboardStatsQueryDTO(),
|
|
1,
|
|
CancellationToken.None);
|
|
|
|
adminResult.TotalDispatchers.Should().Be(2);
|
|
adminResult.Items.Select(item => item.DispatcherName)
|
|
.Should().ContainInOrder("Ada One", "Ben Two");
|
|
|
|
var performance = await NewService(ctx).GetPerformanceAsync(
|
|
AccountUser(1),
|
|
new DashboardStatsQueryDTO(),
|
|
1,
|
|
CancellationToken.None);
|
|
|
|
performance.TotalDispatchers.Should().Be(2);
|
|
performance.Items[0].DispatcherName.Should().Be("Ada One");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetPerformanceAsync_UsesDueOnlyRateAndOriginalDateResolution()
|
|
{
|
|
using var ctx = NewContext();
|
|
ctx.Roles.Add(new IdentityRole
|
|
{
|
|
Id = "dispatcher-role",
|
|
Name = "Dispatcher",
|
|
NormalizedName = "DISPATCHER"
|
|
});
|
|
ctx.Users.Add(new ApplicationUser
|
|
{
|
|
Id = "dispatcher-1",
|
|
FirstName = "Ada",
|
|
LastName = "One",
|
|
Color = "#123456"
|
|
});
|
|
ctx.UserRoles.Add(new IdentityUserRole<string>
|
|
{
|
|
UserId = "dispatcher-1",
|
|
RoleId = "dispatcher-role"
|
|
});
|
|
var today = DashboardBusinessTime.Today();
|
|
ctx.workOrders.AddRange(
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
AssignTo = "dispatcher-1",
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
|
|
ScheduledDate = today.ToDateTime(TimeOnly.MinValue),
|
|
OriginalDate = today.AddDays(-5),
|
|
CompletedDate = today.AddDays(-2).ToDateTime(TimeOnly.MinValue)
|
|
},
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
AssignTo = "dispatcher-1",
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
|
ScheduledDate = today.AddDays(3).ToDateTime(TimeOnly.MinValue)
|
|
});
|
|
ctx.SaveChanges();
|
|
|
|
var result = await NewService(ctx).GetPerformanceAsync(
|
|
AccountUser(1),
|
|
new DashboardStatsQueryDTO(),
|
|
1,
|
|
CancellationToken.None);
|
|
|
|
result.Items.Should().ContainSingle();
|
|
result.Items[0].CompletionRate.Should().Be(100m);
|
|
result.Items[0].AverageResolutionDays.Should().Be(3m);
|
|
result.Items[0].Color.Should().Be("#123456");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetRegionsAsync_UsesCanonicalBucketsAndUnmappedOther()
|
|
{
|
|
await using var ctx = NewContext();
|
|
ctx.Locations.AddRange(
|
|
new Locations { Id = 1, State = "NY" },
|
|
new Locations { Id = 2, State = "ca" },
|
|
new Locations { Id = 3, State = "XX" },
|
|
new Locations { Id = 4, State = "indiana" },
|
|
new Locations { Id = 5, State = "NEW YORK" },
|
|
new Locations { Id = 6, State = "California" });
|
|
ctx.workOrders.AddRange(
|
|
new WorkOrder { AccountId = 1, LocationId = 1 },
|
|
new WorkOrder { AccountId = 1, LocationId = 2 },
|
|
new WorkOrder { AccountId = 1, LocationId = 3 },
|
|
new WorkOrder { AccountId = 1, LocationId = 4 },
|
|
new WorkOrder { AccountId = 1, LocationId = 5 },
|
|
new WorkOrder { AccountId = 1, LocationId = 6 },
|
|
new WorkOrder { AccountId = 1, LocationId = null });
|
|
await ctx.SaveChangesAsync();
|
|
|
|
var result = await NewService(ctx).GetRegionsAsync(
|
|
AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None);
|
|
|
|
result.Items.Select(row => row.Region).Should().Equal(
|
|
"East", "Central", "West", "California", "Unmapped/Other");
|
|
// NY + "NEW YORK" -> East; "indiana" -> Central; "ca" + "California" -> California;
|
|
// "XX" + unassigned location -> Unmapped/Other. Full-name storage forms must not
|
|
// fall through to Unmapped/Other (SH-348 review).
|
|
result.Items.Select(row => row.WorkOrderCount).Should().Equal(2, 1, 0, 2, 2);
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_MapsEveryUsStateAndItsFullNameToACanonicalBucket()
|
|
{
|
|
foreach (var code in UsStateCodes.All)
|
|
{
|
|
var byCode = DashboardRegions.Resolve(code);
|
|
byCode.Should().NotBe(
|
|
"Unmapped/Other",
|
|
$"state code {code} must belong to a canonical region");
|
|
|
|
var fullName = UsStateCodes.ExpandStorageValues(new[] { code })
|
|
.First(value => value != code);
|
|
DashboardRegions.Resolve(fullName).Should().Be(
|
|
byCode,
|
|
$"the full-name storage form of {code} must resolve to the same region as the code");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetVendorInsightsAsync_IsCompanyWideAndUsesVendorMetrics()
|
|
{
|
|
await using var ctx = NewContext();
|
|
ctx.VendorCompanies.Add(new VendorCompany { Id = 10, Name = "Acme Services", IsDeleted = false });
|
|
ctx.Vendors.Add(new Vendor
|
|
{
|
|
Id = 20,
|
|
CompanyId = 10,
|
|
IsActive = true,
|
|
CompanyName = "Acme Services"
|
|
});
|
|
ctx.Dispatches.Add(new Dispatch { Id = 30, VendorId = 20 });
|
|
ctx.workOrders.AddRange(
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
PrimaryDispatchId = 30,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
|
|
ScheduledDate = DateTime.UtcNow.Date.AddDays(-3),
|
|
CompletedDate = DateTime.UtcNow.Date.AddDays(-1),
|
|
RescheduleCount = 1
|
|
},
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
PrimaryDispatchId = 30,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
|
|
ScheduledDate = DateTime.UtcNow.Date.AddDays(-2),
|
|
CompletedDate = DateTime.UtcNow.Date.AddDays(-1)
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
|
|
var result = await NewService(ctx).GetVendorInsightsAsync(
|
|
AccountUser(1), CancellationToken.None);
|
|
|
|
result.TotalVendors.Should().Be(1);
|
|
result.Items.Should().ContainSingle();
|
|
result.Items[0].TotalJobs.Should().Be(2);
|
|
result.Items[0].CompletionRate.Should().Be(100);
|
|
result.Items[0].RescheduleRate.Should().Be(50);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetStatsAsync_DispatcherQuerySelectionCannotBroadenScope()
|
|
{
|
|
using var ctx = NewContext();
|
|
ctx.workOrders.AddRange(
|
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
|
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
|
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
|
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
|
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
|
|
ctx.SaveChanges();
|
|
|
|
var query = new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" };
|
|
var stats = await NewService(ctx).GetStatsAsync(
|
|
AccountUser(1, "dispatcher-1", "Dispatcher"), query, CancellationToken.None);
|
|
|
|
stats.Total.Should().Be(2);
|
|
stats.Open.Should().Be(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetStatsAsync_AdminCanSelectIndividualDispatcherOrMine()
|
|
{
|
|
using var ctx = NewContext();
|
|
ctx.workOrders.AddRange(
|
|
new WorkOrder { AccountId = 1, Status = "Done", AssignTo = "admin-1" },
|
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
|
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
|
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
|
|
ctx.SaveChanges();
|
|
|
|
var service = NewService(ctx);
|
|
|
|
var selected = await service.GetStatsAsync(
|
|
AccountUser(1, "admin-1"),
|
|
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" },
|
|
CancellationToken.None);
|
|
selected.Total.Should().Be(3);
|
|
selected.Open.Should().Be(3);
|
|
|
|
var mine = await service.GetStatsAsync(
|
|
AccountUser(1, "admin-1"),
|
|
new DashboardStatsQueryDTO { DispatcherId = "mine" },
|
|
CancellationToken.None);
|
|
mine.Total.Should().Be(1);
|
|
mine.Completed.Should().Be(1);
|
|
|
|
var all = await service.GetStatsAsync(
|
|
AccountUser(1, "admin-1"),
|
|
new DashboardStatsQueryDTO(),
|
|
CancellationToken.None);
|
|
all.Total.Should().Be(4);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetStatsAsync_ManagerSelectionScopesStats()
|
|
{
|
|
using var ctx = NewContext();
|
|
ctx.workOrders.AddRange(
|
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
|
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
|
|
ctx.SaveChanges();
|
|
|
|
var stats = await NewService(ctx).GetStatsAsync(
|
|
AccountUser(1, "manager-1", "Manager"),
|
|
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" },
|
|
CancellationToken.None);
|
|
|
|
stats.Total.Should().Be(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetStatsAsync_SchedulerSelectionScopesStats()
|
|
{
|
|
using var ctx = NewContext();
|
|
ctx.workOrders.AddRange(
|
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
|
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
|
|
ctx.SaveChanges();
|
|
|
|
var stats = await NewService(ctx).GetStatsAsync(
|
|
AccountUser(1, "scheduler-1", "Scheduler"),
|
|
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" },
|
|
CancellationToken.None);
|
|
|
|
stats.Total.Should().Be(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDashboardAsync_UnauthorizedRoleFailsClosed()
|
|
{
|
|
using var ctx = NewContext();
|
|
var service = NewService(ctx);
|
|
var user = AccountUser(1, "tech-1", "Technician");
|
|
|
|
var statsAct = () => service.GetStatsAsync(
|
|
user, new DashboardStatsQueryDTO(), CancellationToken.None);
|
|
var statsException = await statsAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
|
|
statsException.Which.Code.Should().Be("Forbidden");
|
|
|
|
var workloadAct = () => service.GetWorkloadAsync(
|
|
user, new DashboardStatsQueryDTO(), 1, CancellationToken.None);
|
|
var workloadException = await workloadAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
|
|
workloadException.Which.Code.Should().Be("Forbidden");
|
|
|
|
var performanceAct = () => service.GetPerformanceAsync(
|
|
user, new DashboardStatsQueryDTO(), 1, CancellationToken.None);
|
|
var performanceException = await performanceAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
|
|
performanceException.Which.Code.Should().Be("Forbidden");
|
|
|
|
var regionsAct = () => service.GetRegionsAsync(
|
|
user, new DashboardStatsQueryDTO(), CancellationToken.None);
|
|
var regionsException = await regionsAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
|
|
regionsException.Which.Code.Should().Be("Forbidden");
|
|
|
|
var trendAct = () => service.GetTrendAsync(
|
|
user, new DashboardTrendQueryDTO(), CancellationToken.None);
|
|
var trendException = await trendAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
|
|
trendException.Which.Code.Should().Be("Forbidden");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetWorkloadAsync_IndividualSelectionPreservesRosterWithZeroRows()
|
|
{
|
|
using var ctx = NewContext();
|
|
ctx.Roles.Add(new IdentityRole
|
|
{
|
|
Id = "dispatcher-role",
|
|
Name = "Dispatcher",
|
|
NormalizedName = "DISPATCHER"
|
|
});
|
|
ctx.Users.AddRange(
|
|
new ApplicationUser { Id = "dispatcher-1", FirstName = "Ada", LastName = "One" },
|
|
new ApplicationUser { Id = "dispatcher-2", FirstName = "Ben", LastName = "Two" });
|
|
ctx.UserRoles.AddRange(
|
|
new IdentityUserRole<string> { UserId = "dispatcher-1", RoleId = "dispatcher-role" },
|
|
new IdentityUserRole<string> { UserId = "dispatcher-2", RoleId = "dispatcher-role" });
|
|
ctx.workOrders.AddRange(
|
|
new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", Status = "Open" },
|
|
new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", Status = "Open" });
|
|
ctx.SaveChanges();
|
|
|
|
var workload = await NewService(ctx).GetWorkloadAsync(
|
|
AccountUser(1, "admin-1"),
|
|
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-1" },
|
|
1,
|
|
CancellationToken.None);
|
|
|
|
workload.TotalDispatchers.Should().Be(2);
|
|
workload.Items.Should().HaveCount(2);
|
|
workload.Items[0].DispatcherId.Should().Be("dispatcher-1");
|
|
workload.Items[0].TotalCount.Should().Be(2);
|
|
workload.Items[0].OpenCount.Should().Be(2);
|
|
workload.Items[1].DispatcherId.Should().Be("dispatcher-2");
|
|
workload.Items[1].TotalCount.Should().Be(0);
|
|
workload.Items[1].OpenCount.Should().Be(0);
|
|
workload.Page.Should().Be(1);
|
|
workload.PageSize.Should().Be(10);
|
|
|
|
var performance = await NewService(ctx).GetPerformanceAsync(
|
|
AccountUser(1, "admin-1"),
|
|
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-1" },
|
|
1,
|
|
CancellationToken.None);
|
|
|
|
performance.TotalDispatchers.Should().Be(2);
|
|
performance.Items.Should().HaveCount(2);
|
|
performance.Items[0].DispatcherId.Should().Be("dispatcher-1");
|
|
performance.Items[0].AssignedCount.Should().Be(2);
|
|
performance.Items[1].DispatcherId.Should().Be("dispatcher-2");
|
|
performance.Items[1].AssignedCount.Should().Be(0);
|
|
performance.Items[1].CompletedCount.Should().Be(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetPerformanceAsync_PopulatesAssignedAndCompletedCounts()
|
|
{
|
|
using var ctx = NewContext();
|
|
ctx.Roles.Add(new IdentityRole
|
|
{
|
|
Id = "dispatcher-role",
|
|
Name = "Dispatcher",
|
|
NormalizedName = "DISPATCHER"
|
|
});
|
|
ctx.Users.Add(new ApplicationUser { Id = "dispatcher-1", FirstName = "Ada", LastName = "One" });
|
|
ctx.UserRoles.Add(new IdentityUserRole<string>
|
|
{
|
|
UserId = "dispatcher-1",
|
|
RoleId = "dispatcher-role"
|
|
});
|
|
ctx.workOrders.AddRange(
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
AssignTo = "dispatcher-1",
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed
|
|
},
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
AssignTo = "dispatcher-1",
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled
|
|
},
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
AssignTo = "dispatcher-1",
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled
|
|
});
|
|
ctx.SaveChanges();
|
|
|
|
var result = await NewService(ctx).GetPerformanceAsync(
|
|
AccountUser(1),
|
|
new DashboardStatsQueryDTO(),
|
|
1,
|
|
CancellationToken.None);
|
|
|
|
result.Items.Should().ContainSingle();
|
|
result.Items[0].AssignedCount.Should().Be(3);
|
|
result.Items[0].CompletedCount.Should().Be(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetStatsAsync_StatusDistributionBucketsResolvedStatuses()
|
|
{
|
|
using var ctx = NewContext();
|
|
ctx.workOrders.AddRange(
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled
|
|
},
|
|
new WorkOrder { AccountId = 1, Status = "In Progress" },
|
|
new WorkOrder { AccountId = 1, Status = "Done" },
|
|
new WorkOrder { AccountId = 1, Status = "Done" },
|
|
new WorkOrder { AccountId = 1, Status = "Unparseable" });
|
|
ctx.SaveChanges();
|
|
|
|
var stats = await NewService(ctx).GetStatsAsync(
|
|
AccountUser(1, "admin-1"),
|
|
new DashboardStatsQueryDTO(),
|
|
CancellationToken.None);
|
|
|
|
stats.StatusDistribution.Should().HaveCount(4);
|
|
stats.StatusDistribution
|
|
.Select(bucket => (bucket.Status, bucket.Count))
|
|
.Should().BeEquivalentTo(new[]
|
|
{
|
|
(Status: "Completed", Count: 2),
|
|
(Status: "In Progress", Count: 1),
|
|
(Status: "Scheduled", Count: 1),
|
|
(Status: "Unknown", Count: 1)
|
|
});
|
|
stats.StatusDistribution.Sum(bucket => bucket.Count).Should().Be(5);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetStatsAsync_StatusDistributionIsEmptyWithoutWorkOrders()
|
|
{
|
|
using var ctx = NewContext();
|
|
|
|
var stats = await NewService(ctx).GetStatsAsync(
|
|
AccountUser(1, "admin-1"),
|
|
new DashboardStatsQueryDTO(),
|
|
CancellationToken.None);
|
|
|
|
stats.StatusDistribution.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTrendAsync_DailyBucketsClassifyByCalendarScheduledDate()
|
|
{
|
|
using var ctx = NewContext();
|
|
// ScheduledDate is a stored calendar value (board writes persist `.Date`), so a work
|
|
// order buckets on its own calendar day regardless of the time-of-day component. The
|
|
// UTC hours below were previously shifted a full day by an America/New_York conversion
|
|
// (03:00 UTC -> prior evening), so they double as a regression guard: each row must now
|
|
// stay on the calendar date it was scheduled for.
|
|
ctx.workOrders.AddRange(
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
|
ScheduledDate = new DateTime(2026, 1, 15, 3, 0, 0, DateTimeKind.Utc)
|
|
},
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled,
|
|
ScheduledDate = new DateTime(2026, 1, 14, 15, 0, 0, DateTimeKind.Utc)
|
|
},
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
|
|
ScheduledDate = new DateTime(2026, 1, 15, 17, 0, 0, DateTimeKind.Utc),
|
|
CompletedDate = new DateTime(2026, 1, 16, 15, 0, 0, DateTimeKind.Utc)
|
|
},
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
|
ScheduledDate = new DateTime(2026, 1, 16, 3, 0, 0, DateTimeKind.Utc)
|
|
},
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
|
ScheduledDate = new DateTime(2026, 1, 17, 3, 0, 0, DateTimeKind.Utc)
|
|
},
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
|
ScheduledDate = new DateTime(2026, 1, 13, 20, 0, 0, DateTimeKind.Utc)
|
|
});
|
|
ctx.SaveChanges();
|
|
|
|
var query = new DashboardTrendQueryDTO
|
|
{
|
|
DateFrom = new DateOnly(2026, 1, 14),
|
|
DateTo = new DateOnly(2026, 1, 16)
|
|
};
|
|
var trend = await NewService(ctx).GetTrendAsync(
|
|
AccountUser(1), query, CancellationToken.None);
|
|
|
|
trend.Granularity.Should().Be("day");
|
|
trend.Buckets.Select(bucket => bucket.Date).Should().Equal(
|
|
new DateOnly(2026, 1, 14), new DateOnly(2026, 1, 15), new DateOnly(2026, 1, 16));
|
|
trend.Buckets.Select(bucket => bucket.Label).Should().Equal(
|
|
"2026-01-14", "2026-01-15", "2026-01-16");
|
|
|
|
// 01-14: only the canceled row scheduled that calendar day.
|
|
trend.Buckets[0].Total.Should().Be(1);
|
|
trend.Buckets[0].Open.Should().Be(0);
|
|
trend.Buckets[0].Canceled.Should().Be(1);
|
|
trend.Buckets[0].Completed.Should().Be(0);
|
|
trend.Buckets[0].Overdue.Should().Be(0);
|
|
|
|
// 01-15: the 03:00 UTC scheduled row (no longer shifted to 01-14) plus the completed row.
|
|
trend.Buckets[1].Total.Should().Be(2);
|
|
trend.Buckets[1].Open.Should().Be(1);
|
|
trend.Buckets[1].Completed.Should().Be(1);
|
|
trend.Buckets[1].Canceled.Should().Be(0);
|
|
trend.Buckets[1].Overdue.Should().Be(1);
|
|
|
|
// 01-16: the 03:00 UTC scheduled row that stays on its own day.
|
|
trend.Buckets[2].Total.Should().Be(1);
|
|
trend.Buckets[2].Open.Should().Be(1);
|
|
trend.Buckets[2].Overdue.Should().Be(1);
|
|
|
|
trend.Buckets.Should().OnlyContain(bucket => !bucket.IsCurrent);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTrendAsync_MidnightScheduledDateStaysInTodayBucketAndIsNotOverdue()
|
|
{
|
|
using var ctx = NewContext();
|
|
var today = DashboardBusinessTime.Today();
|
|
// A board-scheduled work order for today is stored as midnight (`.Date`). It must land
|
|
// in the Today bucket and count as open, not roll back to yesterday and read as overdue.
|
|
ctx.workOrders.Add(new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
|
ScheduledDate = today.ToDateTime(TimeOnly.MinValue)
|
|
});
|
|
ctx.SaveChanges();
|
|
|
|
var query = new DashboardTrendQueryDTO
|
|
{
|
|
DateFrom = today.AddDays(-1),
|
|
DateTo = today.AddDays(1)
|
|
};
|
|
var trend = await NewService(ctx).GetTrendAsync(
|
|
AccountUser(1), query, CancellationToken.None);
|
|
|
|
var todayBucket = trend.Buckets.Single(bucket => bucket.Date == today);
|
|
todayBucket.Total.Should().Be(1);
|
|
todayBucket.Open.Should().Be(1);
|
|
todayBucket.Overdue.Should().Be(0);
|
|
|
|
var yesterdayBucket = trend.Buckets.Single(bucket => bucket.Date == today.AddDays(-1));
|
|
yesterdayBucket.Total.Should().Be(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTrendAsync_ThreeMonthRangeUsesWeeklyMondayBuckets()
|
|
{
|
|
using var ctx = NewContext();
|
|
ctx.workOrders.AddRange(
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
|
ScheduledDate = new DateTime(2026, 6, 17, 12, 0, 0, DateTimeKind.Utc)
|
|
},
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
|
ScheduledDate = new DateTime(2026, 6, 16, 3, 0, 0, DateTimeKind.Utc)
|
|
},
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
|
|
ScheduledDate = new DateTime(2026, 9, 16, 12, 0, 0, DateTimeKind.Utc),
|
|
CompletedDate = new DateTime(2026, 9, 16, 18, 0, 0, DateTimeKind.Utc)
|
|
});
|
|
ctx.SaveChanges();
|
|
|
|
var query = new DashboardTrendQueryDTO
|
|
{
|
|
Range = "3m",
|
|
DateFrom = new DateOnly(2026, 6, 17),
|
|
DateTo = new DateOnly(2026, 9, 16)
|
|
};
|
|
var trend = await NewService(ctx).GetTrendAsync(
|
|
AccountUser(1), query, CancellationToken.None);
|
|
|
|
trend.Granularity.Should().Be("week");
|
|
trend.Buckets.Should().HaveCount(14);
|
|
trend.Buckets.Select(bucket => bucket.Date.DayOfWeek)
|
|
.Should().OnlyContain(day => day == DayOfWeek.Monday);
|
|
trend.Buckets[0].Date.Should().Be(new DateOnly(2026, 6, 15));
|
|
trend.Buckets[0].Total.Should().Be(2);
|
|
trend.Buckets[0].Open.Should().Be(2);
|
|
trend.Buckets[^1].Date.Should().Be(new DateOnly(2026, 9, 14));
|
|
trend.Buckets[^1].Total.Should().Be(1);
|
|
trend.Buckets[^1].Completed.Should().Be(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTrendAsync_YearFilterUsesMonthlyBuckets()
|
|
{
|
|
using var ctx = NewContext();
|
|
ctx.workOrders.AddRange(
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
|
ScheduledDate = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc)
|
|
},
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
|
|
ScheduledDate = new DateTime(2026, 3, 20, 4, 0, 0, DateTimeKind.Utc),
|
|
CompletedDate = new DateTime(2026, 3, 21, 4, 0, 0, DateTimeKind.Utc)
|
|
},
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
|
ScheduledDate = new DateTime(2025, 12, 31, 20, 0, 0, DateTimeKind.Utc)
|
|
},
|
|
// 2027-01-01 by calendar: outside the 2026 window. Previously an
|
|
// America/New_York conversion pulled it back to 2026-12-31 and into December.
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
|
ScheduledDate = new DateTime(2027, 1, 1, 3, 0, 0, DateTimeKind.Utc)
|
|
});
|
|
ctx.SaveChanges();
|
|
|
|
var trend = await NewService(ctx).GetTrendAsync(
|
|
AccountUser(1), new DashboardTrendQueryDTO { Year = 2026 }, CancellationToken.None);
|
|
|
|
trend.Granularity.Should().Be("month");
|
|
trend.Buckets.Should().HaveCount(12);
|
|
trend.Buckets[0].Label.Should().Be("2026-01-01");
|
|
trend.Buckets[0].Total.Should().Be(1);
|
|
trend.Buckets[0].Open.Should().Be(1);
|
|
trend.Buckets[0].Overdue.Should().Be(1);
|
|
trend.Buckets[2].Label.Should().Be("2026-03-01");
|
|
trend.Buckets[2].Total.Should().Be(1);
|
|
trend.Buckets[2].Completed.Should().Be(1);
|
|
// December stays empty: the 2027-01-01 row is no longer pulled inside the window.
|
|
trend.Buckets[11].Label.Should().Be("2026-12-01");
|
|
trend.Buckets[11].Total.Should().Be(0);
|
|
trend.Buckets[11].Open.Should().Be(0);
|
|
trend.Today.Should().Be(DashboardBusinessTime.Today());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTrendAsync_MarksTodayBucketAsCurrent()
|
|
{
|
|
using var ctx = NewContext();
|
|
var today = DashboardBusinessTime.Today();
|
|
ctx.workOrders.Add(new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
|
ScheduledDate = today.AddDays(-2).ToDateTime(new TimeOnly(12, 0))
|
|
});
|
|
ctx.SaveChanges();
|
|
|
|
var query = new DashboardTrendQueryDTO
|
|
{
|
|
DateFrom = today.AddDays(-2),
|
|
DateTo = today.AddDays(2)
|
|
};
|
|
var trend = await NewService(ctx).GetTrendAsync(
|
|
AccountUser(1), query, CancellationToken.None);
|
|
|
|
trend.Today.Should().Be(today);
|
|
trend.Buckets.Should().HaveCount(5);
|
|
trend.Buckets.Count(bucket => bucket.IsCurrent).Should().Be(1);
|
|
trend.Buckets.Single(bucket => bucket.IsCurrent).Date.Should().Be(today);
|
|
trend.Buckets[0].Total.Should().Be(1);
|
|
trend.Buckets.Single(bucket => bucket.Date == today).Total.Should().Be(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTrendAsync_DispatcherRoleAndTenantScopeMatchStats()
|
|
{
|
|
using var ctx = NewContext();
|
|
ctx.workOrders.AddRange(
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
AssignTo = "dispatcher-1",
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
|
ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc)
|
|
},
|
|
new WorkOrder
|
|
{
|
|
AccountId = 1,
|
|
AssignTo = "dispatcher-2",
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
|
ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc)
|
|
},
|
|
new WorkOrder
|
|
{
|
|
AccountId = 2,
|
|
AssignTo = "dispatcher-1",
|
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
|
ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc)
|
|
});
|
|
ctx.SaveChanges();
|
|
|
|
var query = new DashboardTrendQueryDTO
|
|
{
|
|
DateFrom = new DateOnly(2026, 2, 2),
|
|
DateTo = new DateOnly(2026, 2, 3)
|
|
};
|
|
|
|
var dispatcherTrend = await NewService(ctx).GetTrendAsync(
|
|
AccountUser(1, "dispatcher-1", "Dispatcher"), query, CancellationToken.None);
|
|
|
|
dispatcherTrend.Buckets.Should().HaveCount(2);
|
|
dispatcherTrend.Buckets[0].Total.Should().Be(1);
|
|
dispatcherTrend.Buckets[1].Total.Should().Be(0);
|
|
|
|
var adminTrend = await NewService(ctx).GetTrendAsync(
|
|
AccountUser(1), query, CancellationToken.None);
|
|
|
|
adminTrend.Buckets[0].Total.Should().Be(2);
|
|
|
|
var otherAccountTrend = await NewService(ctx).GetTrendAsync(
|
|
AccountUser(2), query, CancellationToken.None);
|
|
|
|
otherAccountTrend.Buckets[0].Total.Should().Be(1);
|
|
}
|
|
}
|