shoc-backend/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs

265 lines
11 KiB
C#
Raw Normal View History

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