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; /// /// 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). /// 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() .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 { "__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 { "__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 { "__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()); } }