mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 02:22:11 +00:00
GetKpiCountsAsync computes ScheduledTomorrow, PendingUplifts and AvetaPending and DashboardStatsDTO carries them, but the Stats endpoint's anonymous wire object never serialised them, so the tiles had nothing to read. Add scheduledTomorrow, pendingUplifts and avetaPending to the response. Add a DashboardServiceTests case covering GetKpiCountsAsync end to end: it asserts all three counts and pins the pendingUplifts semantics to Status "Pending" only, excluding "ChangesRequested" (which is back with the vendor, not awaiting an approval decision).
672 lines
26 KiB
C#
672 lines
26 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. A "ChangesRequested" request has been sent
|
|
// back to the vendor and must NOT be counted (SH-353 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(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);
|
|
}
|
|
|
|
[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(1);
|
|
result.Items.Should().ContainSingle();
|
|
result.Items[0].DispatcherName.Should().Be("Ada One");
|
|
result.Items[0].TotalCount.Should().Be(2);
|
|
result.Items[0].OpenCount.Should().Be(1);
|
|
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" });
|
|
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);
|
|
}
|
|
|
|
[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 GetTrendAsync_DailyBucketsClassifyByNewYorkScheduledDate()
|
|
{
|
|
using var ctx = NewContext();
|
|
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");
|
|
|
|
trend.Buckets[0].Total.Should().Be(2);
|
|
trend.Buckets[0].Open.Should().Be(1);
|
|
trend.Buckets[0].Canceled.Should().Be(1);
|
|
trend.Buckets[0].Completed.Should().Be(0);
|
|
trend.Buckets[0].Overdue.Should().Be(1);
|
|
|
|
trend.Buckets[1].Total.Should().Be(2);
|
|
trend.Buckets[1].Open.Should().Be(1);
|
|
trend.Buckets[1].Completed.Should().Be(1);
|
|
trend.Buckets[1].Overdue.Should().Be(1);
|
|
|
|
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_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)
|
|
},
|
|
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);
|
|
trend.Buckets[11].Label.Should().Be("2026-12-01");
|
|
trend.Buckets[11].Total.Should().Be(1);
|
|
trend.Buckets[11].Open.Should().Be(1);
|
|
trend.Buckets[11].Overdue.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);
|
|
}
|
|
}
|