mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
The strict date range made undated open work orders disappear for every client that predates the includeDateless flag. The frontend on main sends the all-weeks window (2000-01-01..2099-12-31) for "no range", the 1970-01-01..2099-12-31 window for the pinned Unassigned queue, and no date input at all for the WO# duplicate lookup. None of those send the flag, so deploying this backend before the frontend would have dropped undated WOs from all three flows. includeDateless is now optional. An explicit value still wins. When it is omitted, a request with no date input or with an all-weeks Custom window keeps its open undated rows, as before SH-391. Any other range stays strict. The backend and frontend can therefore deploy in either order.
264 lines
11 KiB
C#
264 lines
11 KiB
C#
using System.Globalization;
|
||
using Data.SeaHavenIndustries;
|
||
using Data.SeaHavenIndustries.Enums;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using SeaHaven.DataServices.Implementation;
|
||
using SeaHaven.Services.DTOs;
|
||
using SeaHaven.Services.Helpers;
|
||
using SeaHaven.Services.Implementation;
|
||
|
||
namespace SeaHavenIndustries.Tests;
|
||
|
||
/// <summary>
|
||
/// SH-391: an Advanced Filters date range narrows GET /board/search to rows
|
||
/// whose Schedule On (or week-only Target Week) is in the range. Rows with no
|
||
/// date come back only when the caller asks for them (no range selected).
|
||
/// </summary>
|
||
public class WorkOrderAdvancedSearchDateRangeTests
|
||
{
|
||
private static readonly DateOnly RangeFrom = new(2026, 9, 21);
|
||
private static readonly DateOnly RangeTo = new(2026, 9, 25);
|
||
|
||
private static ApplicationDbContext CreateContext()
|
||
{
|
||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||
.Options;
|
||
return new ApplicationDbContext(options);
|
||
}
|
||
|
||
private static WorkOrderAdvancedSearchService CreateService(ApplicationDbContext context)
|
||
=> new(new WorkOrderAdvancedSearchDataService(context), WorkOrderAccountTestHelpers.Resolver(context));
|
||
|
||
private static WorkOrder Wo(
|
||
int id,
|
||
DateTime? scheduled,
|
||
string? assignTo = null,
|
||
LifecycleStatus status = LifecycleStatus.Scheduled,
|
||
DateOnly? targetWeek = null,
|
||
int? accountId = null)
|
||
=> new()
|
||
{
|
||
Id = id,
|
||
InternalWONumber = $"1000000000{id}",
|
||
AssignTo = assignTo,
|
||
ScheduledDate = scheduled,
|
||
ScheduleWeekOnly = targetWeek.HasValue,
|
||
TargetWeek = targetWeek,
|
||
LifecycleStatus = status,
|
||
AccountId = accountId,
|
||
istemplate = false
|
||
};
|
||
|
||
private static WorkOrder NotWeekOnly(WorkOrder wo)
|
||
{
|
||
wo.ScheduleWeekOnly = false;
|
||
return wo;
|
||
}
|
||
|
||
private static WorkOrderAdvancedSearchQueryDto UnassignedInRange(bool? includeDateless = null) => new()
|
||
{
|
||
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
|
||
DateFrom = RangeFrom,
|
||
DateTo = RangeTo,
|
||
Dispatchers = new List<string> { "__unassigned__" },
|
||
IncludeDateless = includeDateless,
|
||
PageSize = 50
|
||
};
|
||
|
||
[Fact]
|
||
public async Task SearchAsync_UnassignedWithRange_ReturnsOnlyUnassignedRowsScheduledInRange()
|
||
{
|
||
await using var context = CreateContext();
|
||
context.workOrders.AddRange(
|
||
Wo(1, new DateTime(2026, 9, 23)),
|
||
Wo(2, new DateTime(2026, 8, 4)),
|
||
Wo(3, null, status: LifecycleStatus.Incomplete),
|
||
Wo(4, new DateTime(2026, 9, 24), assignTo: "user-a"),
|
||
Wo(5, new DateTime(2026, 10, 2)));
|
||
await context.SaveChangesAsync();
|
||
|
||
var result = await CreateService(context).SearchAsync(
|
||
UnassignedInRange(), WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||
|
||
Assert.Equal(1, result.TotalCount);
|
||
Assert.Equal(new[] { 1 }, result.Items.Select(i => i.Id).ToArray());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task SearchAsync_RangeWithoutDispatcherFilter_ExcludesUndatedRows()
|
||
{
|
||
await using var context = CreateContext();
|
||
context.workOrders.AddRange(
|
||
Wo(1, new DateTime(2026, 9, 22), assignTo: "user-a"),
|
||
Wo(2, null, assignTo: "user-a", status: LifecycleStatus.Incomplete),
|
||
Wo(3, new DateTime(2026, 9, 14), assignTo: "user-a", status: LifecycleStatus.Incomplete));
|
||
await context.SaveChangesAsync();
|
||
|
||
var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto
|
||
{
|
||
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
|
||
DateFrom = RangeFrom,
|
||
DateTo = RangeTo
|
||
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||
|
||
Assert.Equal(new[] { 1 }, result.Items.Select(i => i.Id).ToArray());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task SearchAsync_WeekOnlyRow_MatchesRangeThatOverlapsItsTargetWeek()
|
||
{
|
||
await using var context = CreateContext();
|
||
context.workOrders.AddRange(
|
||
Wo(1, null, targetWeek: new DateOnly(2026, 9, 21)),
|
||
Wo(2, null, targetWeek: new DateOnly(2026, 9, 14)),
|
||
Wo(3, null, targetWeek: new DateOnly(2026, 9, 28)),
|
||
// Week-only with a leftover date outside the range: the week decides,
|
||
// as on the board page (SH-391).
|
||
Wo(4, new DateTime(2026, 10, 2), targetWeek: new DateOnly(2026, 9, 21)),
|
||
// Not week-only: its date decides, so a target week does not bring it in.
|
||
NotWeekOnly(Wo(5, new DateTime(2026, 10, 2), targetWeek: new DateOnly(2026, 9, 21))));
|
||
await context.SaveChangesAsync();
|
||
|
||
// Wed–Fri of the week of 9/21: the week-only row for that week overlaps it.
|
||
var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto
|
||
{
|
||
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
|
||
DateFrom = new DateOnly(2026, 9, 23),
|
||
DateTo = RangeTo,
|
||
Dispatchers = new List<string> { "__unassigned__" }
|
||
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||
|
||
Assert.Equal(new[] { 1, 4 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task SearchAsync_IncludeDateless_AddsOpenUndatedRowsToTheRange()
|
||
{
|
||
await using var context = CreateContext();
|
||
context.workOrders.AddRange(
|
||
Wo(1, new DateTime(2026, 9, 23)),
|
||
Wo(2, new DateTime(2026, 8, 4)),
|
||
Wo(3, null, status: LifecycleStatus.Incomplete),
|
||
Wo(4, null, status: LifecycleStatus.Completed),
|
||
Wo(5, null, status: LifecycleStatus.Canceled));
|
||
await context.SaveChangesAsync();
|
||
|
||
var result = await CreateService(context).SearchAsync(
|
||
UnassignedInRange(includeDateless: true), WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||
|
||
Assert.Equal(new[] { 1, 3 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray());
|
||
}
|
||
|
||
// Payloads the frontend sent before includeDateless existed. They must keep
|
||
// their undated rows so this backend can deploy ahead of the client.
|
||
[Theory]
|
||
[InlineData(2000)] // no range selected: ADVANCED_SEARCH_ALL_WEEKS_FROM
|
||
[InlineData(1970)] // pinned Unassigned queue: UNASSIGNED_QUEUE_DATE_FROM
|
||
public async Task SearchAsync_AllWeeksWindowWithoutFlag_KeepsOpenUndatedRows(int fromYear)
|
||
{
|
||
await using var context = CreateContext();
|
||
context.workOrders.AddRange(
|
||
Wo(1, new DateTime(2026, 8, 4)),
|
||
Wo(2, null, status: LifecycleStatus.Incomplete),
|
||
Wo(3, null, status: LifecycleStatus.Completed));
|
||
await context.SaveChangesAsync();
|
||
|
||
var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto
|
||
{
|
||
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
|
||
DateFrom = new DateOnly(fromYear, 1, 1),
|
||
DateTo = new DateOnly(2099, 12, 31),
|
||
Dispatchers = new List<string> { "__unassigned__" },
|
||
PageSize = 50
|
||
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||
|
||
Assert.Equal(new[] { 1, 2 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task SearchAsync_NoDateInputWithoutFlag_KeepsOpenUndatedRows()
|
||
{
|
||
// The WO# duplicate lookup sends only the search text and paging.
|
||
await using var context = CreateContext();
|
||
var thisWeek = WorkOrderSearchDateRangeResolver.GetIsoWeekStart(DateOnly.FromDateTime(DateTime.UtcNow));
|
||
context.workOrders.AddRange(
|
||
Wo(1, thisWeek.ToDateTime(TimeOnly.MinValue)),
|
||
Wo(2, null, status: LifecycleStatus.Incomplete),
|
||
Wo(3, thisWeek.AddDays(-7).ToDateTime(TimeOnly.MinValue)));
|
||
await context.SaveChangesAsync();
|
||
|
||
var result = await CreateService(context).SearchAsync(
|
||
new WorkOrderAdvancedSearchQueryDto { PageSize = 100 },
|
||
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
||
null);
|
||
|
||
Assert.Equal(new[] { 1, 2 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task SearchAsync_AllWeeksWindowWithFlagFalse_ExcludesUndatedRows()
|
||
{
|
||
await using var context = CreateContext();
|
||
context.workOrders.AddRange(
|
||
Wo(1, new DateTime(2026, 8, 4)),
|
||
Wo(2, null, status: LifecycleStatus.Incomplete));
|
||
await context.SaveChangesAsync();
|
||
|
||
var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto
|
||
{
|
||
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
|
||
DateFrom = new DateOnly(2000, 1, 1),
|
||
DateTo = new DateOnly(2099, 12, 31),
|
||
IncludeDateless = false,
|
||
PageSize = 50
|
||
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||
|
||
Assert.Equal(new[] { 1 }, result.Items.Select(i => i.Id).ToArray());
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(null, null, null, null, true)]
|
||
[InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "2000-01-01", "2099-12-31", true)]
|
||
[InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "1970-01-01", "2099-12-31", true)]
|
||
[InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "2000-01-02", "2099-12-31", false)]
|
||
[InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "2026-09-21", "2026-09-25", false)]
|
||
[InlineData(null, WorkOrderAdvancedSearchDatePreset.ThisWeek, null, null, false)]
|
||
[InlineData(false, null, null, null, false)]
|
||
[InlineData(true, WorkOrderAdvancedSearchDatePreset.ThisWeek, null, null, true)]
|
||
public void ResolveIncludeDateless_InfersOnlyForNoRangeRequests(
|
||
bool? flag,
|
||
WorkOrderAdvancedSearchDatePreset? preset,
|
||
string? from,
|
||
string? to,
|
||
bool expected)
|
||
{
|
||
Assert.Equal(expected, WorkOrderSearchDateRangeResolver.ResolveIncludeDateless(
|
||
flag,
|
||
preset,
|
||
from is null ? null : DateOnly.Parse(from, CultureInfo.InvariantCulture),
|
||
to is null ? null : DateOnly.Parse(to, CultureInfo.InvariantCulture)));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task SearchAsync_RangeForAccountUser_NeverReturnsAnotherAccountsRows()
|
||
{
|
||
await using var context = CreateContext();
|
||
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
|
||
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B");
|
||
context.workOrders.AddRange(
|
||
Wo(1, new DateTime(2026, 9, 23), accountId: 1),
|
||
Wo(2, new DateTime(2026, 9, 23), accountId: 2),
|
||
Wo(3, null, status: LifecycleStatus.Incomplete, accountId: 2),
|
||
Wo(4, null, targetWeek: new DateOnly(2026, 9, 21), accountId: 2));
|
||
await context.SaveChangesAsync();
|
||
|
||
var user = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher");
|
||
var strict = await CreateService(context).SearchAsync(UnassignedInRange(), user, "disp-1");
|
||
var withDateless = await CreateService(context).SearchAsync(
|
||
UnassignedInRange(includeDateless: true), user, "disp-1");
|
||
|
||
Assert.Equal(new[] { 1 }, strict.Items.Select(i => i.Id).ToArray());
|
||
Assert.Equal(new[] { 1 }, withDateless.Items.Select(i => i.Id).ToArray());
|
||
}
|
||
}
|