using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using SeaHaven.Services.Validation; namespace SeaHavenIndustries.Tests; // Overdue is a dispatcher-assigned work-order type: no severity, PM catalog // (services and completion-doc templates), and distinct from the derived // past-due overlay. public class WorkOrderOverdueTypeTests { private const int PmServiceId = 10; private const int ReactiveServiceId = 11; private static ApplicationDbContext NewContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new ApplicationDbContext(options); } private static void SeedServices(ApplicationDbContext context) { context.Services.AddRange( NewService(PmServiceId, "Backflow Preventers", WorkOrderType.PM), NewService(ReactiveServiceId, "Leak Repair", WorkOrderType.Reactive)); } private static Service NewService(int id, string name, WorkOrderType type) => new() { Id = id, Name = name, NormalizedName = name.ToUpperInvariant(), Trade = "Plumbing & Water Systems", IconKey = "plumbing-water-systems", IsActive = true, SupportedWorkOrderTypes = new List { new() { WorkOrderType = type } } }; private static WorkOrderBoardCreateService NewCreateService(ApplicationDbContext context) { WorkOrderAccountTestHelpers.SeedBoardCreateScope(context); var boardData = new WorkOrderBoardDataService(context); var resolver = WorkOrderAccountTestHelpers.Resolver(context); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); return new WorkOrderBoardCreateService( boardData, new WorkOrderBoardMutationDataService(context), new WorkOrderBoardService(boardData, resolver), new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks), new WorkOrderBoardCreateValidation(), resolver, new WorkOrderPocDataService(context), new ServicesRegistryService(new ServicesRegistryDataService(context))); } private static WorkOrderBoardUpdateService NewUpdateService(ApplicationDbContext context) { var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); return new WorkOrderBoardUpdateService( new WorkOrderBoardDataService(context), new WorkOrderBoardMutationDataService(context), new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks), new ServicesRegistryService(new ServicesRegistryDataService(context))); } private static string ToVersion(WorkOrder workOrder) => Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); [Fact] public async Task Create_Overdue_WithoutSeverity_PersistsOverdueType() { await using var context = NewContext(); var service = NewCreateService(context); var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.Overdue, SiteCode = "BK5", LocationId = 1 }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType); Assert.Null(result.Severity); var persisted = await context.workOrders.SingleAsync(); Assert.Equal(WorkOrderType.Overdue, persisted.WorkOrderType); Assert.Equal(8, (int)persisted.WorkOrderType!.Value); } [Fact] public async Task Create_Overdue_DropsSuppliedSeverity() { await using var context = NewContext(); var service = NewCreateService(context); var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.Overdue, SiteCode = "BK5", LocationId = 1, Severity = 2 }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); Assert.Null(result.Severity); Assert.Null((await context.workOrders.SingleAsync()).Severity); } [Fact] public async Task Create_Overdue_AcceptsPmCatalogService() { await using var context = NewContext(); SeedServices(context); var service = NewCreateService(context); var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.Overdue, SiteCode = "BK5", LocationId = 1, ServiceId = PmServiceId }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); Assert.Equal(PmServiceId, result.ServiceId); Assert.Equal("Backflow Preventers", (await context.workOrders.SingleAsync()).ServiceNameSnapshot); } [Fact] public async Task Create_Overdue_RejectsReactiveOnlyService() { await using var context = NewContext(); SeedServices(context); var service = NewCreateService(context); var ex = await Assert.ThrowsAsync(() => service.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.Overdue, SiteCode = "BK5", LocationId = 1, ServiceId = ReactiveServiceId }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); Assert.Equal("ServiceInvalid", ex.Code); } [Fact] public async Task PatchType_ToOverdue_ClearsSeverity() { await using var context = NewContext(); var wo = new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled, WorkOrderType = WorkOrderType.Reactive, Severity = "3", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }; context.workOrders.Add(wo); await context.SaveChangesAsync(); var result = await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto { Field = WorkOrderBoardFieldNames.WorkOrderType, Value = "Overdue", WorkOrderVersion = ToVersion(wo) }, "actor-1"); Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType); Assert.Null(result.Severity); var persisted = await context.workOrders.SingleAsync(w => w.Id == 1); Assert.Equal(WorkOrderType.Overdue, persisted.WorkOrderType); Assert.Null(persisted.Severity); } // The board sends one request per field, severity before type, so correcting // Overdue to Emergency patches severity while the stored type is still Overdue. // A severity patch must not be dropped or rejected for the current type; the // no-severity invariant is enforced when the type itself changes. [Fact] public async Task PatchSeverityThenType_OverdueToEmergency_KeepsSeverity() { await using var context = NewContext(); var wo = new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled, WorkOrderType = WorkOrderType.Overdue, RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }; context.workOrders.Add(wo); await context.SaveChangesAsync(); var service = NewUpdateService(context); var severityStep = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto { Field = WorkOrderBoardFieldNames.Severity, Value = "2", WorkOrderVersion = ToVersion(wo) }, "actor-1"); Assert.Null(severityStep.Severity); var updated = await context.workOrders.SingleAsync(w => w.Id == 1); var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto { Field = WorkOrderBoardFieldNames.WorkOrderType, Value = "Emergency", WorkOrderVersion = ToVersion(updated) }, "actor-1"); Assert.Equal(WorkOrderType.Emergency, result.WorkOrderType); Assert.Equal(2, result.Severity); var persisted = await context.workOrders.SingleAsync(w => w.Id == 1); Assert.Equal(WorkOrderType.Emergency, persisted.WorkOrderType); Assert.Equal("2", persisted.Severity); } // A severity patch with no following type change leaves a stored value on an // Overdue WO; the board row still reports no severity. [Fact] public async Task PatchSeverity_OnOverdue_RowShowsNoSeverity() { await using var context = NewContext(); var wo = new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled, WorkOrderType = WorkOrderType.Overdue, RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }; context.workOrders.Add(wo); await context.SaveChangesAsync(); var result = await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto { Field = WorkOrderBoardFieldNames.Severity, Value = "3", WorkOrderVersion = ToVersion(wo) }, "actor-1"); Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType); Assert.Null(result.Severity); } [Fact] public async Task PatchType_PmWithPmService_ToOverdue_KeepsService() { await using var context = NewContext(); SeedServices(context); var wo = new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled, WorkOrderType = WorkOrderType.PM, ServiceId = PmServiceId, RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }; context.workOrders.Add(wo); await context.SaveChangesAsync(); var result = await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto { Field = WorkOrderBoardFieldNames.WorkOrderType, Value = "Overdue", WorkOrderVersion = ToVersion(wo) }, "actor-1"); Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType); Assert.Equal(PmServiceId, (await context.workOrders.SingleAsync(w => w.Id == 1)).ServiceId); } [Fact] public async Task PatchServiceId_OnOverdue_AcceptsPmCatalogService() { await using var context = NewContext(); SeedServices(context); var wo = new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled, WorkOrderType = WorkOrderType.Overdue, RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }; context.workOrders.Add(wo); await context.SaveChangesAsync(); await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto { Field = WorkOrderBoardFieldNames.ServiceId, Value = PmServiceId.ToString(), WorkOrderVersion = ToVersion(wo) }, "actor-1"); var persisted = await context.workOrders.SingleAsync(w => w.Id == 1); Assert.Equal(PmServiceId, persisted.ServiceId); Assert.Equal("Backflow Preventers", persisted.Trade); } [Fact] public async Task GetDetail_Overdue_ResolvesPmCompletionTemplate() { await using var context = NewContext(); context.workOrders.Add(new WorkOrder { Id = 1, InternalWONumber = "10000000001", LifecycleStatus = LifecycleStatus.Scheduled, WorkOrderType = WorkOrderType.Overdue, ScheduledDate = DateTime.UtcNow.AddDays(1), DocStatus = DocStatus.No, RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); context.CompletionDocTemplates.Add(new CompletionDocTemplate { Name = "PM Completion", ServiceKey = "pm-generic", WorkOrderType = WorkOrderType.PM, TemplateUrl = "https://example.com/pm.pdf", IsActive = true }); await context.SaveChangesAsync(); var boardData = new WorkOrderBoardDataService(context); var resolver = WorkOrderAccountTestHelpers.Resolver(context); var service = new WorkOrderDetailService( new WorkOrderBoardService(boardData, resolver), new WorkOrderDetailDataService(context), new CompletionDocTemplateDataService(context), new UserDataService(context), resolver); var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); Assert.NotNull(detail); Assert.Equal(DocStatus.No, detail!.Completion.DocStatus); Assert.NotNull(detail.Completion.Template); Assert.Equal("PM Completion", detail.Completion.Template!.Name); } [Fact] public async Task ListServices_Overdue_ReturnsPmCatalog() { await using var context = NewContext(); SeedServices(context); await context.SaveChangesAsync(); var services = await new ServicesRegistryService(new ServicesRegistryDataService(context)) .GetAllAsync(true, WorkOrderType.Overdue, CancellationToken.None); Assert.Equal(new[] { PmServiceId }, services.Select(s => s.Id).ToArray()); } [Fact] public async Task ListTemplates_Overdue_ReturnsPmTemplates() { await using var context = NewContext(); context.CompletionDocTemplates.AddRange( new CompletionDocTemplate { Name = "PM Completion", ServiceKey = "pm-generic", WorkOrderType = WorkOrderType.PM, TemplateUrl = "https://example.com/pm.pdf", IsActive = true }, new CompletionDocTemplate { Name = "Reactive Completion", ServiceKey = "reactive-generic", WorkOrderType = WorkOrderType.Reactive, TemplateUrl = "https://example.com/reactive.pdf", IsActive = true }); await context.SaveChangesAsync(); var service = new CompletionDocTemplateService( new CompletionDocTemplateDataService(context), new TeamPermissionOverrideDataService(context), new TeamPermissionPolicy(), WorkOrderAccountTestHelpers.Resolver(context)); var templates = await service.ListAsync(null, null, WorkOrderType.Overdue, CancellationToken.None); Assert.Equal(new[] { "PM Completion" }, templates.Select(t => t.Name).ToArray()); } [Fact] public async Task Search_TypeOverdue_ReturnsOverdueTypeOnly_NotPastDueRows() { await using var context = NewContext(); var today = DateTime.UtcNow.Date; context.workOrders.AddRange( new WorkOrder { Id = 1, WorkOrderType = WorkOrderType.Overdue, ScheduledDate = today.AddDays(2), 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.Overdue, ScheduledDate = today.AddDays(-2), LifecycleStatus = LifecycleStatus.Scheduled, InternalWONumber = "10000000003" }); await context.SaveChangesAsync(); var result = await NewSearchService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto { DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)), DateTo = DateOnly.FromDateTime(today.AddMonths(1)), Types = new List { WorkOrderType.Overdue } }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); Assert.Equal(new[] { 1, 3 }, result.Items.Select(i => i.Id).OrderBy(id => id)); Assert.All(result.Items, i => Assert.Equal(WorkOrderType.Overdue, i.WorkOrderType)); } [Fact] public async Task Search_TypeOverdueWithPastDue_ReturnsIntersection() { await using var context = NewContext(); var today = DateTime.UtcNow.Date; context.workOrders.AddRange( new WorkOrder { Id = 1, WorkOrderType = WorkOrderType.Overdue, ScheduledDate = today.AddDays(2), 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.Overdue, ScheduledDate = today.AddDays(-2), LifecycleStatus = LifecycleStatus.Scheduled, InternalWONumber = "10000000003" }); await context.SaveChangesAsync(); var result = await NewSearchService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto { DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)), DateTo = DateOnly.FromDateTime(today.AddMonths(1)), Types = new List { WorkOrderType.Overdue }, Overdue = true }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); var row = Assert.Single(result.Items); Assert.Equal(3, row.Id); Assert.True(row.IsPastDue); } [Fact] public void DashboardBreakdown_CountsByTypeOnly() { var today = new DateOnly(2026, 9, 25); var pastDue = today.AddDays(-3).ToDateTime(TimeOnly.MinValue); var future = today.AddDays(3).ToDateTime(TimeOnly.MinValue); var rows = new List { new(1, null, WorkOrderType.Overdue, LifecycleStatus.Scheduled, null, future, null, null), new(2, null, WorkOrderType.PM, LifecycleStatus.Scheduled, null, pastDue, null, null), new(3, null, WorkOrderType.Overdue, LifecycleStatus.Scheduled, null, pastDue, null, null), }; var result = DashboardMetrics.Calculate(rows, today); Assert.Equal(2, result.Breakdown.Overdue); Assert.Equal(1, result.Breakdown.PM); Assert.Equal(0, result.Breakdown.Other); } private static WorkOrderAdvancedSearchService NewSearchService(ApplicationDbContext context) => new(new WorkOrderAdvancedSearchDataService(context), WorkOrderAccountTestHelpers.Resolver(context)); }