diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs index 909540c..0886c11 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs @@ -48,6 +48,8 @@ namespace SeaHaven.DataServices.Helpers || (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s)) || (w.Trade ?? "").ToLower().Contains(s) || (w.Problem ?? "").ToLower().Contains(s) + || (w.ServiceDefinition != null && (w.ServiceDefinition.Name ?? "").ToLower().Contains(s)) + || (w.ServiceNameSnapshot ?? "").ToLower().Contains(s) || (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s)) || (w.TechnicianAssigned != false && w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.ContactName ?? "").ToLower().Contains(s)) || (w.Status ?? "").ToLower().Contains(s) diff --git a/SeaHavenIndustries.Tests/WorkOrderServiceRegistryKeywordSearchRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderServiceRegistryKeywordSearchRelationalTests.cs new file mode 100644 index 0000000..6af82c5 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderServiceRegistryKeywordSearchRelationalTests.cs @@ -0,0 +1,210 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHavenIndustries.Tests; + +public sealed class WorkOrderServiceRegistryKeywordSearchRelationalTests +{ + [Fact] + public async Task Search_MatchesCurrentAndHistoricalServiceLabelsAndPreservesExistingAliases() + { + await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new SqliteSearchTestDbContext(options); + await context.Database.EnsureCreatedAsync(); + + context.Services.AddRange( + new Service + { + Id = 10, + Name = "Chiller Maintenance Original", + NormalizedName = "CHILLER MAINTENANCE ORIGINAL", + Trade = "HVAC", + IconKey = "hvac", + IsActive = true + }, + new Service + { + Id = 20, + Name = "Roof Maintenance Current", + NormalizedName = "ROOF MAINTENANCE CURRENT", + Trade = "Roofing", + IconKey = "roofing", + IsActive = true + }, + new Service + { + Id = 30, + Name = "Pump Service Current", + NormalizedName = "PUMP SERVICE CURRENT", + Trade = "Plumbing", + IconKey = "plumbing", + IsActive = false + }); + context.Vendors.Add(new Vendor + { + Id = 40, + CompanyName = "Acme Vendor Group", + ContactName = "Morgan Technician" + }); + context.Dispatches.Add(new Dispatch { Id = 50, VendorId = 40, Status = "Sent" }); + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + ServiceId = 10, + ServiceNameSnapshot = "Legacy Chiller Label", + Trade = "HVAC", + Problem = "Cooling noise", + LifecycleStatus = LifecycleStatus.Scheduled, + WorkOrderType = WorkOrderType.PM, + ScheduledDate = new DateTime(2026, 9, 15), + PrimaryDispatchId = 50, + TechnicianAssigned = true + }, + new WorkOrder + { + Id = 2, + ServiceId = 20, + ServiceNameSnapshot = "Frozen Roof Label", + Trade = "Roofing", + Problem = "Roof leak", + LifecycleStatus = LifecycleStatus.Completed, + WorkOrderType = WorkOrderType.PM, + ScheduledDate = new DateTime(2026, 9, 15) + }, + new WorkOrder + { + Id = 3, + ServiceId = 30, + ServiceNameSnapshot = "Historical Pump Label", + Trade = "Plumbing", + Problem = "Pump noise", + LifecycleStatus = LifecycleStatus.Scheduled, + WorkOrderType = WorkOrderType.PM, + ScheduledDate = new DateTime(2026, 9, 15) + }, + new WorkOrder + { + Id = 4, + Trade = "Legacy Boiler Service", + Problem = "Oil leak", + LifecycleStatus = LifecycleStatus.Scheduled, + WorkOrderType = WorkOrderType.Reactive, + ScheduledDate = new DateTime(2026, 9, 15) + }, + new WorkOrder + { + Id = 5, + Trade = "Unlinked vendor WO", + LifecycleStatus = LifecycleStatus.Scheduled, + WorkOrderType = WorkOrderType.Reactive, + ScheduledDate = new DateTime(2026, 9, 15), + PrimaryDispatchId = 50, + TechnicianAssigned = false + }); + await context.SaveChangesAsync(); + + var renamedService = await context.Services.SingleAsync(service => service.Id == 10); + renamedService.Name = "Northstar Chiller Service"; + renamedService.NormalizedName = "NORTHSTAR CHILLER SERVICE"; + await context.SaveChangesAsync(); + + async Task SearchIds(string search) + => await WorkOrderBoardSearchFilter + .Apply(context.workOrders.AsNoTracking(), search) + .Select(workOrder => workOrder.Id) + .OrderBy(id => id) + .ToArrayAsync(); + + Assert.Equal(new[] { 1 }, await SearchIds("Northstar Chiller Service")); + Assert.Equal(new[] { 1 }, await SearchIds("Legacy Chiller Label")); + Assert.Equal(new[] { 2 }, await SearchIds("Frozen Roof Label")); + Assert.Equal(new[] { 2 }, await SearchIds("Roof Maintenance Current")); + Assert.Equal(new[] { 3 }, await SearchIds("Historical Pump Label")); + Assert.Equal(new[] { 3 }, await SearchIds("Pump Service Current")); + Assert.Equal(new[] { 4 }, await SearchIds("Legacy Boiler Service")); + Assert.Equal(new[] { 4 }, await SearchIds("Oil leak")); + Assert.Equal(new[] { 1, 5 }, await SearchIds("Acme Vendor Group")); + Assert.Equal(new[] { 1 }, await SearchIds("Morgan Technician")); + + var advancedSearch = new WorkOrderAdvancedSearchDataService(context); + var matchingFacet = await advancedSearch.SearchAsync(CreateQuery( + search: "Northstar Chiller Service", + pmTypes: new[] { "Northstar Chiller Service" })); + Assert.Equal(new[] { 1 }, matchingFacet.Rows.Select(row => row.Id)); + Assert.Equal("Northstar Chiller Service", Assert.Single(matchingFacet.Rows).Trade); + + var conflictingFacet = await advancedSearch.SearchAsync(CreateQuery( + search: "Northstar Chiller Service", + pmTypes: new[] { "Roof Maintenance Current" })); + Assert.Empty(conflictingFacet.Rows); + Assert.Equal(0, conflictingFacet.TotalCount); + } + + private static WorkOrderAdvancedSearchQuery CreateQuery(string search, IReadOnlyList? pmTypes = null) + => new( + Search: search, + DateFrom: new DateOnly(2026, 9, 1), + DateTo: new DateOnly(2026, 9, 30), + UnscheduledOnly: false, + Sites: null, + Regions: null, + Types: null, + Overdue: false, + Dispatchers: null, + Statuses: null, + PmTypes: pmTypes, + VendorIds: null, + DocStatuses: null, + Severities: null, + Rescheduled: false, + CarriedOver: false, + AddOn: false, + AvetaOnly: false, + FlagColors: null, + InternalOnly: false, + HasUplift: false, + UpliftStatuses: null, + MyWorkOrders: false, + CurrentUserId: null, + Page: 0, + PageSize: 100, + SortBy: "scheduledDate", + SortDir: "asc"); + + private sealed class SqliteSearchTestDbContext : ApplicationDbContext + { + public SqliteSearchTestDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes())) + { + if (index.GetFilter() != null) + index.SetFilter(null); + } + + foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) }) + { + var property = builder.Entity(entityType).Property("RowVersion").Metadata; + property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never; + property.IsConcurrencyToken = false; + } + } + } +}