2026-09-16 17:12:03 -03:00
|
|
|
using System.Security.Claims;
|
2026-07-24 17:35:34 -03:00
|
|
|
using Data.SeaHavenIndustries;
|
|
|
|
|
using FluentAssertions;
|
2026-09-16 17:43:49 -03:00
|
|
|
using Microsoft.AspNetCore.Identity;
|
2026-07-24 17:35:34 -03:00
|
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
|
using SeaHaven.DataServices.Implementation;
|
2026-09-16 17:12:03 -03:00
|
|
|
using SeaHaven.Services.Exceptions;
|
2026-09-16 17:28:21 -03:00
|
|
|
using SeaHaven.Services.DTOs;
|
2026-09-16 17:12:03 -03:00
|
|
|
using SeaHaven.Services.Helpers;
|
2026-07-24 17:35:34 -03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 17:12:03 -03:00
|
|
|
private static DashboardService NewService(ApplicationDbContext ctx)
|
|
|
|
|
{
|
|
|
|
|
var resolver = new WorkOrderAccountResolver(
|
|
|
|
|
new AccountDataService(ctx),
|
|
|
|
|
new LocationDataService(ctx));
|
|
|
|
|
return new DashboardService(new DashboardDataService(ctx), resolver);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 17:28:21 -03:00
|
|
|
private static ClaimsPrincipal AccountUser(
|
|
|
|
|
int accountId,
|
|
|
|
|
string userId = "dispatcher-1",
|
|
|
|
|
string role = "Admin")
|
2026-09-16 17:12:03 -03:00
|
|
|
{
|
|
|
|
|
var claims = new[]
|
|
|
|
|
{
|
|
|
|
|
new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
|
2026-09-16 17:28:21 -03:00
|
|
|
new Claim(ClaimTypes.NameIdentifier, userId),
|
|
|
|
|
new Claim(ClaimTypes.Role, role)
|
2026-09-16 17:12:03 -03:00
|
|
|
};
|
|
|
|
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
|
|
|
|
}
|
2026-07-24 17:35:34 -03:00
|
|
|
|
2026-09-16 17:12:03 -03:00
|
|
|
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"));
|
|
|
|
|
}
|
2026-07-24 17:35:34 -03:00
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task GetStatsAsync_CountsByStatusExcludingTemplates()
|
|
|
|
|
{
|
|
|
|
|
using var ctx = NewContext();
|
|
|
|
|
ctx.workOrders.AddRange(
|
2026-09-16 17:12:03 -03:00
|
|
|
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" }
|
2026-07-24 17:35:34 -03:00
|
|
|
);
|
|
|
|
|
ctx.SaveChanges();
|
|
|
|
|
|
2026-09-16 17:28:21 -03:00
|
|
|
var stats = await NewService(ctx).GetStatsAsync(
|
|
|
|
|
AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None);
|
2026-07-24 17:35:34 -03:00
|
|
|
|
|
|
|
|
stats.Total.Should().Be(7);
|
|
|
|
|
stats.Open.Should().Be(4);
|
|
|
|
|
stats.NotDispatched.Should().Be(1);
|
|
|
|
|
stats.Completed.Should().Be(2);
|
|
|
|
|
}
|
2026-09-16 17:12:03 -03:00
|
|
|
|
|
|
|
|
[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();
|
|
|
|
|
|
2026-09-16 17:28:21 -03:00
|
|
|
var stats = await NewService(ctx).GetStatsAsync(
|
|
|
|
|
OrgWideUser(), new DashboardStatsQueryDTO(), CancellationToken.None);
|
2026-09-16 17:12:03 -03:00
|
|
|
|
|
|
|
|
stats.Total.Should().Be(2);
|
|
|
|
|
stats.Open.Should().Be(1);
|
|
|
|
|
stats.Completed.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"));
|
|
|
|
|
|
2026-09-16 17:28:21 -03:00
|
|
|
var act = () => NewService(ctx).GetStatsAsync(
|
|
|
|
|
user, new DashboardStatsQueryDTO(), CancellationToken.None);
|
2026-09-16 17:12:03 -03:00
|
|
|
|
|
|
|
|
var exception = await act.Should().ThrowAsync<WorkOrderBoardValidationException>();
|
|
|
|
|
|
|
|
|
|
exception.Which.Code.Should().Be("Forbidden");
|
|
|
|
|
}
|
2026-09-16 17:28:21 -03:00
|
|
|
|
|
|
|
|
[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(1);
|
|
|
|
|
stats.Breakdown.Other.Should().Be(0);
|
|
|
|
|
stats.Breakdown.PM.Should().Be(2);
|
|
|
|
|
stats.Breakdown.Emergency.Should().Be(0);
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-09-16 17:43:49 -03:00
|
|
|
|
|
|
|
|
[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);
|
|
|
|
|
|
2026-09-16 21:37:17 -03:00
|
|
|
result.TotalDispatchers.Should().Be(2);
|
|
|
|
|
result.Items.Select(item => item.DispatcherName)
|
|
|
|
|
.Should().ContainInOrder("Ada One", "Ben Two");
|
2026-09-16 17:43:49 -03:00
|
|
|
result.Items[0].TotalCount.Should().Be(2);
|
|
|
|
|
result.Items[0].OpenCount.Should().Be(1);
|
2026-09-16 21:37:17 -03:00
|
|
|
result.Items[1].TotalCount.Should().Be(0);
|
|
|
|
|
result.Items[1].OpenCount.Should().Be(0);
|
2026-09-16 17:43:49 -03:00
|
|
|
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");
|
2026-09-16 17:58:08 -03:00
|
|
|
|
|
|
|
|
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");
|
2026-09-16 17:43:49 -03:00
|
|
|
}
|
2026-09-16 18:01:42 -03:00
|
|
|
|
|
|
|
|
[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" });
|
|
|
|
|
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 = 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");
|
|
|
|
|
result.Items.Select(row => row.WorkOrderCount).Should().Equal(1, 0, 0, 1, 2);
|
|
|
|
|
}
|
2026-09-16 18:10:42 -03:00
|
|
|
|
|
|
|
|
[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);
|
|
|
|
|
}
|
2026-09-16 21:37:17 -03:00
|
|
|
|
|
|
|
|
[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 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");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[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();
|
|
|
|
|
}
|
2026-07-24 17:35:34 -03:00
|
|
|
}
|