mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
ScheduledDate is persisted as a midnight calendar value (board create writes request.ScheduledDate.Value.Date; board patch writes the parsed .Date into a datetime2 column with no offset). GetTrendAsync treated it as a UTC instant and converted to America/New_York, so midnight became 19:00/20:00 the previous day and every board-scheduled work order fell into the prior bucket: a job scheduled today read as yesterday and overdue, and the Today bucket showed zero. Bucket by DateOnly.FromDateTime(scheduled.Date) with no conversion, matching DashboardMetrics and WorkOrderDerivedFields, so Trend and Stats agree on the same rows. Rewrite the daily and yearly trend tests to assert calendar-date classification (the removed conversion had encoded the shift into their expectations) and add a regression test that a midnight-today work order stays in the Today bucket and is not overdue.
668 lines
26 KiB
C#
668 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_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_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);
|
|
}
|
|
}
|