shoc-backend/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs
Alexandre Brandizzi 67cd9c589b fix(board-search): keep undated rows for clients that omit includeDateless (SH-391)
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.
2026-09-25 01:41:49 -03:00

264 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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());
}
}