shoc-backend/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs
Arthur Bassi 620a36af54
feat(work-orders): enrich board search overdue filters and 0-based paging (#23)
* feat(work-orders): enrich board search overdue filters and 0-based paging

* fix(work-orders): align stacked services with CI build

* fix(tests): pass userDataService in comment service unit test

* fix(work-orders): use dedicated overdue query flag

Stop treating WorkOrderType.Other as an overdue sentinel. Board and advanced search now accept overdue=true while types=Other filters real Other rows; combining both uses OR.

* test(work-orders): cover overdue date/status boundary and Other type-filter

Lock the PR #23 overdue regression boundary through the public advanced
search service. Prove overdue filtering is driven by past-due date plus
non-terminal status, not by the WorkOrderType.Other sentinel:
- Other + future/not-completed excluded from overdue
- past-due + Scheduled included; past-due + Completed/Canceled excluded
- types=[PM, Other] keeps real Other rows and does not pull past-due rows
- assert 0-based paging (Page=0) is preserved alongside overdue/type filters

---------

Co-authored-by: Arthur Bassi <arthur.winiarski.ranger@outlook.com>
Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
2026-07-24 21:12:03 +00:00

608 lines
21 KiB
C#

using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Implementation;
namespace SeaHavenIndustries.Tests;
public class WorkOrderBoardSearchFilterTests
{
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
[Theory]
[InlineData("BK5", 1)]
[InlineData("10000000001", 1)]
[InlineData("HVAC Tech", 1)]
[InlineData("Acme HVAC", 1)]
[InlineData("sched", 1)]
[InlineData("Leak repair", 1)]
[InlineData("Reactive", 1)]
[InlineData("6/23", 1)]
public async Task Apply_MatchesExpectedField(string search, int expectedId)
{
await using var context = CreateContext();
await SeedSearchFixtures(context);
var ids = await WorkOrderBoardSearchFilter
.Apply(context.workOrders.AsQueryable(), search)
.Select(w => w.Id)
.ToListAsync();
Assert.Contains(expectedId, ids);
}
[Fact]
public void NormalizeSearch_IgnoresSingleCharacter()
{
Assert.Null(WorkOrderBoardSearchFilter.NormalizeSearch("a"));
Assert.Null(WorkOrderBoardSearchFilter.NormalizeSearch(" "));
Assert.Equal("ab", WorkOrderBoardSearchFilter.NormalizeSearch(" ab "));
}
[Fact]
public async Task Apply_LifecycleLabel_MatchesScheduled()
{
await using var context = CreateContext();
await SeedSearchFixtures(context);
var ids = await WorkOrderBoardSearchFilter
.Apply(context.workOrders.AsQueryable(), "scheduled")
.Select(w => w.Id)
.ToListAsync();
Assert.Single(ids);
Assert.Equal(1, ids[0]);
}
private static async Task SeedSearchFixtures(ApplicationDbContext context)
{
context.Vendors.Add(new Vendor
{
Id = 1,
CompanyName = "Acme HVAC",
ContactName = "HVAC Tech",
Phone = "555-0100"
});
context.Dispatches.Add(new Dispatch { Id = 10, VendorId = 1 });
context.workOrders.Add(new WorkOrder
{
Id = 1,
SiteCode = "BK5",
InternalWONumber = "10000000001",
WorkerOrderTitle = "Leak repair",
WorkOrderType = WorkOrderType.Reactive,
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10
});
await context.SaveChangesAsync();
}
}
public class WorkOrderBoardSearchCountsTests
{
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
[Fact]
public async Task GetBoardRows_SearchReducesScheduledAndKeepsTotal()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
SiteCode = "BK5",
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled
},
new WorkOrder
{
Id = 2,
SiteCode = "BK6",
ScheduledDate = new DateTime(2026, 6, 24),
LifecycleStatus = LifecycleStatus.Scheduled
},
new WorkOrder
{
Id = 3,
SiteCode = "BK5",
ScheduledDate = null,
LifecycleStatus = LifecycleStatus.Incomplete
});
await context.SaveChangesAsync();
var service = new WorkOrderBoardDataService(context);
var result = await service.GetBoardRowsAsync(new SeaHaven.DataServices.Interfaces.WorkOrderBoardQuery(
weekStart,
weekStart.AddDays(4),
null,
false,
null,
false,
"BK5",
null));
Assert.Equal(2, result.ScheduledTotalBeforeSearch);
Assert.Single(result.ScheduledRows);
Assert.Equal("BK5", result.ScheduledRows[0].SiteCode);
Assert.Single(result.UnscheduledRows);
Assert.Equal("BK5", result.UnscheduledRows[0].SiteCode);
}
}
public class WorkOrderSearchDateRangeResolverTests
{
[Fact]
public void Resolve_ThisWeek_UsesIsoMonday()
{
var reference = new DateOnly(2026, 6, 24);
var range = SeaHaven.Services.Helpers.WorkOrderSearchDateRangeResolver.Resolve(
SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek,
null,
null,
reference);
Assert.Equal(new DateOnly(2026, 6, 22), range.From);
Assert.Equal(new DateOnly(2026, 6, 26), range.To);
}
[Fact]
public void Resolve_Custom_RequiresDates()
{
var ex = Assert.Throws<ArgumentException>(() =>
SeaHaven.Services.Helpers.WorkOrderSearchDateRangeResolver.Resolve(
SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
null,
null));
Assert.Contains("dateFrom", ex.Message);
}
}
public class WorkOrderAdvancedSearchServiceTests
{
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
[Fact]
public async Task SearchAsync_FiltersBySiteAndPaginates()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
SiteCode = "BK5",
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
new WorkOrder
{
Id = 2,
SiteCode = "BK6",
ScheduledDate = new DateTime(2026, 6, 24),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002"
});
await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = weekStart,
DateTo = weekStart.AddDays(6),
Sites = new List<string> { "BK5" },
Page = 0,
PageSize = 50
}, null);
Assert.Equal(1, result.TotalCount);
Assert.Single(result.Items);
Assert.Equal("BK5", result.Items.First().SiteCode);
}
[Fact]
public async Task SearchAsync_ClampsPageSizeToMax()
{
await using var context = CreateContext();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek,
PageSize = 500
}, null);
Assert.Equal(100, result.PageSize);
}
[Fact]
public async Task SearchAsync_PageZero_ReturnsFirstPage()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
SiteCode = "BK5",
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
new WorkOrder
{
Id = 2,
SiteCode = "BK6",
ScheduledDate = new DateTime(2026, 6, 24),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002"
});
await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = weekStart,
DateTo = weekStart.AddDays(4),
Page = 0,
PageSize = 1
}, null);
Assert.Equal(2, result.TotalCount);
Assert.Single(result.Items);
Assert.Equal(0, result.Page);
}
[Fact]
public async Task SearchAsync_TypesOther_ReturnsOtherRowsNotPastDueSentinel()
{
await using var context = CreateContext();
var today = DateTime.UtcNow.Date;
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = today.AddDays(-3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
new WorkOrder
{
Id = 2,
WorkOrderType = WorkOrderType.Other,
ScheduledDate = today.AddDays(3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002"
});
await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
Types = new List<WorkOrderType> { WorkOrderType.Other }
}, null);
Assert.Equal(1, result.TotalCount);
Assert.Equal(2, result.Items.First().Id);
Assert.Equal(WorkOrderType.Other, result.Items.First().WorkOrderType);
Assert.False(result.Items.First().IsPastDue);
}
[Fact]
public async Task SearchAsync_OverdueTrue_ReturnsPastDueRowsOnly()
{
await using var context = CreateContext();
var today = DateTime.UtcNow.Date;
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = today.AddDays(-3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
new WorkOrder
{
Id = 2,
WorkOrderType = WorkOrderType.Other,
ScheduledDate = today.AddDays(3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002"
},
new WorkOrder
{
Id = 3,
WorkOrderType = WorkOrderType.Reactive,
ScheduledDate = today.AddDays(1),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000003"
});
await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
Overdue = true
}, null);
Assert.Equal(1, result.TotalCount);
Assert.Equal(1, result.Items.First().Id);
Assert.True(result.Items.First().IsPastDue);
}
[Fact]
public async Task SearchAsync_OtherPlusOverdue_ReturnsUnion()
{
await using var context = CreateContext();
var today = DateTime.UtcNow.Date;
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = today.AddDays(-3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
new WorkOrder
{
Id = 2,
WorkOrderType = WorkOrderType.Other,
ScheduledDate = today.AddDays(3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002"
},
new WorkOrder
{
Id = 3,
WorkOrderType = WorkOrderType.Reactive,
ScheduledDate = today.AddDays(1),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000003"
});
await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
Types = new List<WorkOrderType> { WorkOrderType.Other },
Overdue = true
}, null);
Assert.Equal(2, result.TotalCount);
Assert.Contains(result.Items, i => i.Id == 1 && i.IsPastDue);
Assert.Contains(result.Items, i => i.Id == 2 && i.WorkOrderType == WorkOrderType.Other);
Assert.DoesNotContain(result.Items, i => i.Id == 3);
}
[Fact]
public async Task SearchAsync_OverdueDerivesFromDateAndStatus_NotWorkOrderType()
{
await using var context = CreateContext();
var today = DateTime.UtcNow.Date;
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
WorkOrderType = WorkOrderType.Other,
ScheduledDate = today.AddDays(5),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
new WorkOrder
{
Id = 2,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = today.AddDays(-3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002"
},
new WorkOrder
{
Id = 3,
WorkOrderType = WorkOrderType.Reactive,
ScheduledDate = today.AddDays(-2),
LifecycleStatus = LifecycleStatus.Completed,
InternalWONumber = "10000000003"
},
new WorkOrder
{
Id = 4,
WorkOrderType = WorkOrderType.Reactive,
ScheduledDate = today.AddDays(-1),
LifecycleStatus = LifecycleStatus.Canceled,
InternalWONumber = "10000000004"
});
await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
Overdue = true,
Page = 0,
PageSize = 50
}, null);
var overdue = Assert.Single(result.Items);
Assert.Equal(0, result.Page);
Assert.Equal(2, overdue.Id);
Assert.True(overdue.IsPastDue);
Assert.DoesNotContain(result.Items, i => i.Id == 1);
Assert.DoesNotContain(result.Items, i => i.Id == 3);
Assert.DoesNotContain(result.Items, i => i.Id == 4);
}
[Fact]
public async Task SearchAsync_TreatsOtherAsRealTypeFilter_NotOverdueSentinel()
{
await using var context = CreateContext();
var today = DateTime.UtcNow.Date;
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = today.AddDays(2),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
new WorkOrder
{
Id = 2,
WorkOrderType = WorkOrderType.Other,
ScheduledDate = today.AddDays(2),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002"
},
new WorkOrder
{
Id = 3,
WorkOrderType = WorkOrderType.Reactive,
ScheduledDate = today.AddDays(-3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000003"
});
await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
Types = new List<WorkOrderType> { WorkOrderType.PM, WorkOrderType.Other },
Page = 0,
PageSize = 50
}, null);
Assert.Equal(2, result.TotalCount);
Assert.Equal(0, result.Page);
Assert.Contains(result.Items, i => i.Id == 1 && i.WorkOrderType == WorkOrderType.PM);
Assert.Contains(result.Items, i => i.Id == 2 && i.WorkOrderType == WorkOrderType.Other);
Assert.DoesNotContain(result.Items, i => i.Id == 3);
}
[Fact]
public async Task SearchAsync_IncludesUnscheduledRows()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
new WorkOrder
{
Id = 2,
ScheduledDate = null,
LifecycleStatus = LifecycleStatus.Incomplete,
InternalWONumber = "10000000002"
});
await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = weekStart,
DateTo = weekStart.AddDays(4)
}, null);
Assert.Equal(2, result.TotalCount);
Assert.Contains(result.Items, i => i.Id == 2);
}
[Fact]
public async Task SearchAsync_InvalidSortBy_Throws()
{
await using var context = CreateContext();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
var ex = await Assert.ThrowsAsync<ArgumentException>(() => service.SearchAsync(
new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek,
SortBy = "invalid"
}, null));
Assert.Contains("sortBy", ex.Message);
}
}