using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Moq; using SeaHaven.DataServices.Implementation; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using System.Security.Claims; using Xunit; namespace Api.SeaHavenIndustries.Tests; // Behavior tests for the approval queue read contract. These exercise the real // service + data-service layers against an in-memory DbContext so that queue // ordering, filters, flattened work-order fields, exposure aggregation, and // per-tier read authorization are validated through the public contract. public sealed class UpliftQueueReadTests { private static ApplicationDbContext NewContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new ApplicationDbContext(options); } private static ApprovalsOptions NewOptions() => new() { UpliftTier1MaxUsd = 2500m, Tier1Roles = new[] { "Approver" }, Tier2Roles = new[] { "Manager" }, Tier1NotificationRecipients = new[] { "tier1@example.com" }, Tier2NotificationRecipients = new[] { "tier2@example.com" }, EscalationRecipients = new[] { "escalate@example.com" } }; private static UpliftService NewService(ApplicationDbContext context) => new(new UpliftDataService(context), new DispatchDataService(context), Mock.Of(), TimeProvider.System, Microsoft.Extensions.Options.Options.Create(NewOptions())); private static ClaimsPrincipal UserWithRoles(params string[] roles) { var claims = new List { new(ClaimTypes.NameIdentifier, "user-42") }; claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r))); return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); } private static DispatchUpliftRequest Request( Dispatch dispatch, string status, DateTime created, DateTime? decided = null, int tier = 1, decimal requested = 100m, string? reason = null, string? requestedBy = null, string? createdBy = null, decimal? currentNte = null) => new() { DispatchId = dispatch.Id, Status = status, CreatedDate = created, DecidedAt = decided, RequiredTier = tier, RequestedNTE = requested, CurrentNTE = currentNte, VendorReason = reason, RequestedByVendorName = requestedBy, createdby = createdBy, NotificationStatus = "Pending" }; private static async Task<(Vendor Vendor, WorkOrder WorkOrder)> SeedWorkOrderAsync( ApplicationDbContext context, string number, string site, string service, string vendorName = "Gateway") { var vendor = new Vendor { CompanyName = vendorName, IsActive = true }; var workOrder = new WorkOrder { InternalWONumber = number, WorkerOrderNumber = $"legacy-{number}", SiteCode = site, Service = service, WorkerOrderTitle = "Repair" }; context.AddRange(vendor, workOrder); await context.SaveChangesAsync(); return (vendor, workOrder); } private static async Task SeedDispatchAsync( ApplicationDbContext context, Vendor vendor, WorkOrder workOrder, string number) { var dispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = workOrder.Id, DispatchNumber = number, Status = "Completed", NTEAmount = 1000m }; context.Dispatches.Add(dispatch); await context.SaveChangesAsync(); return dispatch; } // --- Ordering --- [Fact] public async Task List_PendingStatus_OrdersOldestRequestFirst() { using var context = NewContext(); var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; context.AddRange(vendor, workOrder); await context.SaveChangesAsync(); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); context.DispatchUpliftRequests.AddRange( Request(dispatch, "Pending", new DateTime(2026, 3, 3)), Request(dispatch, "Pending", new DateTime(2026, 3, 1)), Request(dispatch, "Pending", new DateTime(2026, 3, 2))); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); result.Items.Select(i => i.RequestedAt).Should().Equal( new DateTime(2026, 3, 1), new DateTime(2026, 3, 2), new DateTime(2026, 3, 3)); } [Fact] public async Task List_ApprovedStatus_OrdersMostRecentlyDecidedFirst() { using var context = NewContext(); var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; context.AddRange(vendor, workOrder); await context.SaveChangesAsync(); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); context.DispatchUpliftRequests.AddRange( Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 10)), Request(dispatch, "Approved", new DateTime(2026, 3, 2), decided: new DateTime(2026, 3, 20)), Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 15))); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None); result.Items.Select(i => i.DecidedAt).Should().Equal( new DateTime(2026, 3, 20), new DateTime(2026, 3, 15), new DateTime(2026, 3, 10)); } [Fact] public async Task List_RejectedStatus_OrdersMostRecentlyRejectedFirst() { using var context = NewContext(); var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; context.AddRange(vendor, workOrder); await context.SaveChangesAsync(); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); // Request order (3/1, 3/2, 3/3) deliberately disagrees with decision order. context.DispatchUpliftRequests.AddRange( Request(dispatch, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 10)), Request(dispatch, "Rejected", new DateTime(2026, 3, 2), decided: new DateTime(2026, 3, 20)), Request(dispatch, "Rejected", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 15))); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Approver"), "Rejected", null, 1, 25, CancellationToken.None); result.Items.Select(i => i.DecidedAt).Should().Equal( new DateTime(2026, 3, 20), new DateTime(2026, 3, 15), new DateTime(2026, 3, 10)); } [Fact] public async Task List_RejectedStatus_ReturnsOnlyRejectedRequests_IncludingLegacyDenied() { using var context = NewContext(); var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; context.AddRange(vendor, workOrder); await context.SaveChangesAsync(); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); context.DispatchUpliftRequests.AddRange( Request(dispatch, "Pending", new DateTime(2026, 3, 1)), Request(dispatch, "Approved", new DateTime(2026, 3, 2), decided: new DateTime(2026, 3, 3)), Request(dispatch, "Revoked", new DateTime(2026, 3, 4), decided: new DateTime(2026, 3, 5)), Request(dispatch, "Withdrawn", new DateTime(2026, 3, 6)), Request(dispatch, "Rejected", new DateTime(2026, 3, 7), decided: new DateTime(2026, 3, 8)), Request(dispatch, "Denied", new DateTime(2026, 2, 1), decided: new DateTime(2026, 2, 2))); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Approver"), "Rejected", null, 1, 25, CancellationToken.None); result.Total.Should().Be(2); result.Items.Select(i => i.Status).Should().Equal("Rejected", "Rejected"); result.Items.Select(i => i.DecidedAt).Should().Equal( new DateTime(2026, 3, 8), new DateTime(2026, 2, 2)); } [Fact] public async Task List_WithoutStatusFilter_KeepsHistoricalNewestRequestFirstOrder() { using var context = NewContext(); var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; context.AddRange(vendor, workOrder); await context.SaveChangesAsync(); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); context.DispatchUpliftRequests.AddRange( Request(dispatch, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)), Request(dispatch, "Approved", new DateTime(2026, 3, 10), decided: new DateTime(2026, 3, 11))); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Approver"), null, null, 1, 25, CancellationToken.None); result.Items.Select(i => i.RequestedAt).Should().ContainInOrder( new DateTime(2026, 3, 10), new DateTime(2026, 3, 1)); } // --- Filters and scoping --- [Fact] public async Task List_StatusAndTierFilters_Combine() { using var context = NewContext(); var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; context.AddRange(vendor, workOrder); await context.SaveChangesAsync(); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); context.DispatchUpliftRequests.AddRange( Request(dispatch, "Pending", new DateTime(2026, 3, 1), tier: 1), Request(dispatch, "Pending", new DateTime(2026, 3, 2), tier: 2), Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), tier: 2)); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Manager"), "Pending", 2, 1, 25, CancellationToken.None); result.Total.Should().Be(1); result.Items.Should().ContainSingle(i => i.RequiredTier == 2 && i.Status == "Pending"); } [Fact] public async Task List_ExcludesDeletedRequestsAndRequestsOnDeletedDispatches() { using var context = NewContext(); var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; context.AddRange(vendor, workOrder); await context.SaveChangesAsync(); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); var deletedDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = workOrder.Id, DispatchNumber = "DIS-DELETED", Status = "Completed", IsDeleted = true }; context.Dispatches.Add(deletedDispatch); await context.SaveChangesAsync(); context.DispatchUpliftRequests.AddRange( Request(dispatch, "Pending", new DateTime(2026, 3, 1)), new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "Pending", CreatedDate = new DateTime(2026, 3, 2), IsDeleted = true, RequiredTier = 1, RequestedNTE = 100m }, Request(deletedDispatch, "Pending", new DateTime(2026, 3, 3))); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); result.Total.Should().Be(1); result.Items.Should().ContainSingle(i => i.RequestedAt == new DateTime(2026, 3, 1)); } // --- Flattened queue fields --- [Fact] public async Task List_MapsFlattenedWorkOrderAndRequesterFields() { using var context = NewContext(); var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC"); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" }); await context.SaveChangesAsync(); context.DispatchUpliftRequests.Add(Request( dispatch, "Pending", new DateTime(2026, 3, 2), tier: 2, requested: 400m, reason: "Scope grew", requestedBy: "Gateway", createdBy: "user-7", currentNte: 100m)); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Manager"), "Pending", null, 1, 25, CancellationToken.None); var item = result.Items.Should().ContainSingle().Subject; item.WorkOrderId.Should().Be(workOrder.Id); item.WorkOrderNumber.Should().Be("WO-77"); item.WorkOrderSite.Should().Be("SITE-EAST"); item.WorkOrderService.Should().Be("HVAC"); item.VendorCompanyName.Should().Be("Gateway"); item.RequestedByName.Should().Be("Gateway"); item.RequestedAt.Should().Be(new DateTime(2026, 3, 2)); item.VendorReason.Should().Be("Scope grew"); // A work-order request (createdby set) stores the increase itself in RequestedNTE. item.Delta.Should().Be(400m); item.Status.Should().Be("Pending"); } [Fact] public async Task List_MapsDetailModalWorkOrderContext_DispatcherTechnicianAndScheduledDate() { using var context = NewContext(); var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-81", "SITE-N", "Plumbing"); vendor.ContactName = " Tom Tech "; workOrder.ScheduledDate = new DateTime(2026, 4, 10, 9, 30, 0); workOrder.AssignTo = "dispatcher-1"; context.Users.Add(new ApplicationUser { Id = "dispatcher-1", FirstName = "Dana", LastName = "Ruiz" }); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-81"); context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 4, 1))); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); var item = result.Items.Should().ContainSingle().Subject; item.WorkOrderScheduledDate.Should().Be(new DateTime(2026, 4, 10, 9, 30, 0)); item.WorkOrderDispatcherName.Should().Be("Dana Ruiz"); item.TechnicianName.Should().Be("Tom Tech"); item.VendorCompanyName.Should().Be("Gateway"); } [Fact] public async Task List_DetailModalWorkOrderContext_IsNullForUnassignedUnscheduledWorkOrder() { using var context = NewContext(); var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-82", "SITE-N", "Plumbing"); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-82"); context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 4, 1))); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); var item = result.Items.Should().ContainSingle().Subject; item.WorkOrderScheduledDate.Should().BeNull(); item.WorkOrderDispatcherName.Should().BeNull(); item.TechnicianName.Should().BeNull(); } [Fact] public async Task List_DetailModalWorkOrderContext_NeverCrossesAccounts() { // Two tenants' work orders sit side by side. Each queue row must carry the // dispatcher, technician and schedule of its own work order and vendor only; // another account's work order never supplies them. using var context = NewContext(); var accountA = new Accounts { Name = "Account A" }; var accountB = new Accounts { Name = "Account B" }; context.AddRange(accountA, accountB); context.Users.AddRange( new ApplicationUser { Id = "dispatcher-a", FirstName = "Ann", LastName = "Alpha" }, new ApplicationUser { Id = "dispatcher-b", FirstName = "Ben", LastName = "Beta" }); await context.SaveChangesAsync(); var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC", "Vendor A"); vendorA.ContactName = "Tech A"; workOrderA.AccountId = accountA.Id; workOrderA.AssignTo = "dispatcher-a"; workOrderA.ScheduledDate = new DateTime(2026, 5, 1); var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Roofing", "Vendor B"); vendorB.ContactName = "Tech B"; workOrderB.AccountId = accountB.Id; workOrderB.AssignTo = "dispatcher-b"; workOrderB.ScheduledDate = new DateTime(2026, 6, 1); var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); context.DispatchUpliftRequests.AddRange( Request(dispatchA, "Pending", new DateTime(2026, 4, 1)), Request(dispatchB, "Pending", new DateTime(2026, 4, 2))); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); var rowA = result.Items.Should().ContainSingle(i => i.WorkOrderId == workOrderA.Id).Subject; rowA.WorkOrderDispatcherName.Should().Be("Ann Alpha"); rowA.TechnicianName.Should().Be("Tech A"); rowA.WorkOrderScheduledDate.Should().Be(new DateTime(2026, 5, 1)); var rowB = result.Items.Should().ContainSingle(i => i.WorkOrderId == workOrderB.Id).Subject; rowB.WorkOrderDispatcherName.Should().Be("Ben Beta"); rowB.TechnicianName.Should().Be("Tech B"); rowB.WorkOrderScheduledDate.Should().Be(new DateTime(2026, 6, 1)); } [Fact] public async Task List_ResolvesWorkOrderContext_ForDispatchLinkedOnlyThroughDispatchWorkOrders() { // A dispatch can carry no primary WorkOrderId yet be linked to a work order // through DispatchWorkOrders (the supported shape exercised by // Exposure_IncludesLinkedDispatchesViaServerDerivedWorkOrderLinks). The queue // read must resolve the same effective work order the sibling reads use, or the // Approved tab surfaces blank WO context, workOrderClosed:false, and zero // exposure on a terminal work order. using var context = NewContext(); var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-LINKED", "SITE-Z", "Electrical"); workOrder.LifecycleStatus = LifecycleStatus.Completed; var linkedDispatch = new Dispatch { VendorId = vendor.Id, DispatchNumber = "DIS-LINKED", Status = "Completed" }; context.Dispatches.Add(linkedDispatch); await context.SaveChangesAsync(); context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = linkedDispatch.Id, WorkOrderId = workOrder.Id }); context.DispatchUpliftRequests.Add(Request( linkedDispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 90m)); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None); var item = result.Items.Should().ContainSingle().Subject; item.WorkOrderId.Should().Be(workOrder.Id); item.WorkOrderNumber.Should().Be("WO-LINKED"); item.WorkOrderSite.Should().Be("SITE-Z"); item.WorkOrderService.Should().Be("Electrical"); item.WorkOrderClosed.Should().BeTrue(); item.WorkOrderApprovedExposureTotal.Should().Be(90m); } [Fact] public async Task List_WorkOrderNumber_RendersInternalShNumber_NotTheCrmExternalId() { using var context = NewContext(); var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; // Synced work orders carry the CRM external id in WorkerOrderNumber; the SH // display number the board renders is stamped on InternalWONumber. var workOrder = new WorkOrder { InternalWONumber = "10000000001", WorkerOrderNumber = "CRM-EXT-77", SiteCode = "SITE-EAST", Service = "HVAC", WorkerOrderTitle = "Repair" }; context.AddRange(vendor, workOrder); await context.SaveChangesAsync(); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 3, 2))); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); var item = result.Items.Should().ContainSingle().Subject; item.WorkOrderNumber.Should().Be("10000000001"); item.WorkOrderNumber.Should().NotBe("CRM-EXT-77"); } [Fact] public async Task List_RequesterLabelFallsBackToCreatingUserName() { using var context = NewContext(); var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC"); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" }); await context.SaveChangesAsync(); context.DispatchUpliftRequests.Add(Request( dispatch, "Pending", new DateTime(2026, 3, 2), requestedBy: null, createdBy: "user-7")); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); result.Items.Single().RequestedByName.Should().Be("Ada Smith"); } [Fact] public async Task List_MapsEvidenceAttachmentSummary() { using var context = NewContext(); var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC"); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); var evidence = new VendorCompletionDocument { VendorId = vendor.Id, DispatchId = dispatch.Id, WorkOrderId = workOrder.Id, OriginalFileName = "quote.pdf", StoredFileName = "evidence.bin", ContentType = "application/pdf", SizeBytes = 512, ScanStatus = "Passed", ReviewStatus = "Approved", Purpose = "UpliftEvidence", Version = 1 }; context.VendorCompletionDocuments.Add(evidence); await context.SaveChangesAsync(); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "Pending", CreatedDate = new DateTime(2026, 3, 2), RequiredTier = 1, RequestedNTE = 200m, EvidenceDocumentId = evidence.Id }); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); var item = result.Items.Single(); item.EvidenceDocumentId.Should().Be(evidence.Id); item.EvidenceFileName.Should().Be("quote.pdf"); item.EvidenceContentType.Should().Be("application/pdf"); item.EvidenceSizeBytes.Should().Be(512); } // --- Queue contract fields (SH-208/SH-213) --- [Fact] public async Task List_MarksWorkOrderClosed_OnlyForTerminalLifecycle() { using var context = NewContext(); var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Plumbing"); workOrderA.LifecycleStatus = LifecycleStatus.Completed; workOrderB.LifecycleStatus = LifecycleStatus.Canceled; await context.SaveChangesAsync(); var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); var (vendorC, workOrderC) = await SeedWorkOrderAsync(context, "WO-C", "SITE-C", "Electrical"); var dispatchC = await SeedDispatchAsync(context, vendorC, workOrderC, "DIS-C"); context.DispatchUpliftRequests.AddRange( Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)), Request(dispatchB, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4)), Request(dispatchC, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6))); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Admin"), "Approved", null, 1, 25, CancellationToken.None); result.Items.Single(i => i.WorkOrderNumber == "WO-A").WorkOrderClosed.Should().BeTrue(); result.Items.Single(i => i.WorkOrderNumber == "WO-B").WorkOrderClosed.Should().BeTrue(); result.Items.Single(i => i.WorkOrderNumber == "WO-C").WorkOrderClosed.Should().BeFalse(); } [Fact] public async Task List_ApprovedRow_CarriesDecidedByNameFromDecidingUser() { using var context = NewContext(); var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A"); context.Users.Add(new ApplicationUser { Id = "user-9", FirstName = "Grace", LastName = "Hopper" }); await context.SaveChangesAsync(); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "Approved", CreatedDate = new DateTime(2026, 3, 1), DecidedAt = new DateTime(2026, 3, 2), DecidedByUserId = "user-9", RequiredTier = 1, RequestedNTE = 300m, NotificationStatus = "Pending" }); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None); result.Items.Single().DecidedByName.Should().Be("Grace Hopper"); } [Fact] public async Task List_RejectedRow_CarriesRejecterRequesterDecisionTimeAndReason() { using var context = NewContext(); var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-R", "SITE-R", "Plumbing"); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-R"); context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Lovelace" }); await context.SaveChangesAsync(); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "Rejected", CreatedDate = new DateTime(2026, 3, 1), DecidedAt = new DateTime(2026, 3, 2, 16, 40, 0), DecidedByUserId = "user-7", DecisionNote = "Outside this work order's scope", RequestedByVendorName = "Gateway", RequiredTier = 1, RequestedNTE = 250m, NotificationStatus = "Pending" }); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Approver"), "Rejected", null, 1, 25, CancellationToken.None); var row = result.Items.Single(); row.DecidedByName.Should().Be("Ada Lovelace"); row.RequestedByName.Should().Be("Gateway"); row.DecidedAt.Should().Be(new DateTime(2026, 3, 2, 16, 40, 0)); row.DecisionNote.Should().Be("Outside this work order's scope"); row.WorkOrderNumber.Should().Be("WO-R"); } [Fact] public async Task List_PendingExposureTotal_SumsEachPendingRequestsIncreaseAcrossTheQueue() { using var context = NewContext(); var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A"); context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" }); await context.SaveChangesAsync(); context.DispatchUpliftRequests.AddRange( // Vendor-portal rows (createdby null) store the requested new NTE total, // so their increase is 400 - 100 = 300 and 600 - 250 = 350. Request(dispatch, "Pending", new DateTime(2026, 3, 1), requested: 400m, currentNte: 100m), Request(dispatch, "Pending", new DateTime(2026, 3, 2), requested: 600m, currentNte: 250m), // Work-order rows (createdby set) store the increase itself: 90. Request(dispatch, "Pending", new DateTime(2026, 3, 3), requested: 90m, currentNte: 600m, createdBy: "user-7"), // Non-pending rows never add pending exposure. Request(dispatch, "Approved", new DateTime(2026, 2, 1), decided: new DateTime(2026, 2, 2), requested: 500m, currentNte: 100m)); await context.SaveChangesAsync(); var service = NewService(context); // Page 1 with a single row still reports the total across ALL pending rows. var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 1, CancellationToken.None); result.Items.Should().HaveCount(1); result.PendingExposureTotal.Should().Be(740m); } [Fact] public async Task List_PendingExposureTotal_EqualsSumOfPendingRowAmounts_ForBothCreationPaths() { using var context = NewContext(); var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "HVAC"); var (vendorC, workOrderC) = await SeedWorkOrderAsync(context, "WO-C", "SITE-C", "HVAC"); var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); var deletedDispatch = await SeedDispatchAsync(context, vendorC, workOrderC, "DIS-C"); deletedDispatch.IsDeleted = true; context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" }); await context.SaveChangesAsync(); context.DispatchUpliftRequests.AddRange( // Raised from the work order, which already carries a 3,000 NTE: RequestedNTE // is the 5,000,000,000 increase itself. Request(dispatchA, "Pending", new DateTime(2026, 3, 1), requested: 5_000_000_000m, currentNte: 3_000m, createdBy: "user-7"), // Vendor-portal request for a 3,333 total on a 3,000 NTE: a 333 increase. Request(dispatchB, "Pending", new DateTime(2026, 3, 2), requested: 3_333m, currentNte: 3_000m), // A pending request on a deleted dispatch is not a queue row, so it adds nothing. Request(deletedDispatch, "Pending", new DateTime(2026, 3, 3), requested: 700m, currentNte: 100m)); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); result.Items.Should().HaveCount(2); result.Items.Select(i => i.Delta).Should().BeEquivalentTo(new[] { 5_000_000_000m, 333m }); result.PendingExposureTotal.Should().Be(5_000_000_333m); result.PendingExposureTotal.Should().Be(result.Items.Sum(i => i.Delta)); } // --- Approved-on-WO exposure totals --- [Fact] public async Task List_AggregatesApprovedExposurePerWorkOrder_ExcludingNonApprovedStatuses() { using var context = NewContext(); var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC", "Gateway-A"); var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Plumbing", "Gateway-B"); var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); context.DispatchUpliftRequests.AddRange( // Vendor-portal row (createdby null): stores the requested new NTE total, // so the granted amount is 400 - 100 = 300. Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 400m, currentNte: 100m), new DispatchUpliftRequest { DispatchId = dispatchA.Id, Status = "NoApprovalRequired", CreatedDate = new DateTime(2026, 3, 1), RequiredTier = 0, RequestedNTE = 150m }, Request(dispatchA, "Pending", new DateTime(2026, 3, 1), requested: 999m), Request(dispatchA, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 999m), new DispatchUpliftRequest { DispatchId = dispatchA.Id, Status = "Withdrawn", CreatedDate = new DateTime(2026, 3, 1), DecidedAt = new DateTime(2026, 3, 2), RequiredTier = 1, RequestedNTE = 999m }, new DispatchUpliftRequest { DispatchId = dispatchA.Id, Status = "Revoked", CreatedDate = new DateTime(2026, 3, 1), DecidedAt = new DateTime(2026, 3, 2), RequiredTier = 1, RequestedNTE = 999m }, new DispatchUpliftRequest { DispatchId = dispatchA.Id, Status = "Expired", CreatedDate = new DateTime(2026, 3, 1), RequiredTier = 1, RequestedNTE = 999m }, Request(dispatchB, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 75m, currentNte: 0m)); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None); var itemA = result.Items.Single(i => i.WorkOrderNumber == "WO-A"); itemA.WorkOrderAutoApprovedTotal.Should().Be(150m); itemA.WorkOrderAdminApprovedTotal.Should().Be(300m); itemA.WorkOrderApprovedExposureTotal.Should().Be(450m); var itemB = result.Items.Single(i => i.WorkOrderNumber == "WO-B"); itemB.WorkOrderAutoApprovedTotal.Should().Be(0m); itemB.WorkOrderAdminApprovedTotal.Should().Be(75m); itemB.WorkOrderApprovedExposureTotal.Should().Be(75m); } [Fact] public async Task Exposure_UsesGrantedAmountForVendorPortalAndRequestedAmountForWorkOrderRequests() { using var context = NewContext(); var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-ORIGIN", "SITE-A", "HVAC"); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-ORIGIN"); context.DispatchUpliftRequests.AddRange( Request( dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 400m, currentNte: 100m), Request( dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 250m, createdBy: "internal-user", currentNte: 100m)); await context.SaveChangesAsync(); var exposure = await new UpliftDataService(context) .GetApprovedExposureForWorkOrdersAsync(new[] { workOrder.Id }, CancellationToken.None); exposure.Should().ContainSingle().Which.AdminApprovedTotal.Should().Be(550m); } [Fact] public async Task Exposure_IncludesLinkedDispatchesViaServerDerivedWorkOrderLinks() { using var context = NewContext(); var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); var linkedDispatch = new Dispatch { VendorId = vendorA.Id, DispatchNumber = "DIS-LINKED", Status = "Completed" }; context.Dispatches.Add(linkedDispatch); await context.SaveChangesAsync(); context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = linkedDispatch.Id, WorkOrderId = workOrderA.Id }); await context.SaveChangesAsync(); context.DispatchUpliftRequests.Add(Request( linkedDispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 60m)); await context.SaveChangesAsync(); var data = new UpliftDataService(context); var exposure = await data.GetApprovedExposureForWorkOrdersAsync( new[] { workOrderA.Id }, CancellationToken.None); var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrderA.Id).Subject; total.AdminApprovedTotal.Should().Be(60m); total.AutoApprovedTotal.Should().Be(0m); } [Fact] public async Task Exposure_AdminApprovedSumsGrantedAmountsPerCreationPath() { using var context = NewContext(); var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A"); context.DispatchUpliftRequests.AddRange( // Vendor-portal rows (createdby null) store the requested new NTE total: // 1000 -> 1500 grants 500, then 1500 -> 1800 grants 300. Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 1500m, currentNte: 1000m), Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 1800m, currentNte: 1500m), // Work-order-path rows (createdby set) store the granted increment: 200. Request(dispatch, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 200m, currentNte: 1800m, createdBy: "user-7")); await context.SaveChangesAsync(); var data = new UpliftDataService(context); var exposure = await data.GetApprovedExposureForWorkOrdersAsync( new[] { workOrder.Id }, CancellationToken.None); // Granted exposure is 500 + 300 + 200; summing raw RequestedNTE would // double-count whole NTE totals and report 3500. var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrder.Id).Subject; total.AdminApprovedTotal.Should().Be(1000m); total.AutoApprovedTotal.Should().Be(0m); } [Fact] public async Task Exposure_AutoApprovedSumsGrantedAmountsPerCreationPath() { using var context = NewContext(); var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A"); context.DispatchUpliftRequests.AddRange( // Vendor-portal rows (createdby null) store the requested new NTE total: // 1000 -> 1500 grants 500, then 1500 -> 1800 grants 300. Request(dispatch, "NoApprovalRequired", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 1500m, currentNte: 1000m), Request(dispatch, "NoApprovalRequired", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 1800m, currentNte: 1500m), // Work-order-path rows (createdby set) store the granted increment: 200. Request(dispatch, "NoApprovalRequired", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 200m, currentNte: 1800m, createdBy: "user-7")); await context.SaveChangesAsync(); var data = new UpliftDataService(context); var exposure = await data.GetApprovedExposureForWorkOrdersAsync( new[] { workOrder.Id }, CancellationToken.None); // Granted exposure is 500 + 300 + 200; summing raw RequestedNTE would // double-count whole NTE totals and report 3500. var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrder.Id).Subject; total.AutoApprovedTotal.Should().Be(1000m); total.AdminApprovedTotal.Should().Be(0m); } // --- Read authorization (tier roles) --- [Fact] public async Task List_CanDecide_ReflectsTierRolesForTheCaller() { using var context = NewContext(); var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC"); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); context.DispatchUpliftRequests.AddRange( Request(dispatch, "Pending", new DateTime(2026, 3, 1), tier: 1), Request(dispatch, "Pending", new DateTime(2026, 3, 2), tier: 2)); await context.SaveChangesAsync(); var service = NewService(context); var tier1Only = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); var tier2User = await service.ListAsync(UserWithRoles("Manager"), "Pending", null, 1, 25, CancellationToken.None); tier1Only.Items.Single(i => i.RequiredTier == 1).CanDecide.Should().BeTrue(); tier1Only.Items.Single(i => i.RequiredTier == 2).CanDecide.Should().BeFalse(); tier2User.Items.Single(i => i.RequiredTier == 2).CanDecide.Should().BeTrue(); tier2User.Items.Single(i => i.RequiredTier == 1).CanDecide.Should().BeFalse(); } // --- Closed-work-order flag and decider (SH-208) --- [Fact] public async Task List_MarksWorkOrderClosedOnlyForTerminalLifecycleStatuses() { using var context = NewContext(); var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-OPEN", "SITE-A", "HVAC"); var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-DONE", "SITE-B", "HVAC"); var (vendorC, workOrderC) = await SeedWorkOrderAsync(context, "WO-CANCEL", "SITE-C", "HVAC"); workOrderA.LifecycleStatus = LifecycleStatus.InProgress; workOrderB.LifecycleStatus = LifecycleStatus.Completed; workOrderC.LifecycleStatus = LifecycleStatus.Canceled; await context.SaveChangesAsync(); var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); var dispatchC = await SeedDispatchAsync(context, vendorC, workOrderC, "DIS-C"); context.DispatchUpliftRequests.AddRange( Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)), Request(dispatchB, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)), Request(dispatchC, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2))); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None); result.Items.Single(i => i.WorkOrderNumber == "WO-OPEN").WorkOrderClosed.Should().BeFalse(); result.Items.Single(i => i.WorkOrderNumber == "WO-DONE").WorkOrderClosed.Should().BeTrue(); result.Items.Single(i => i.WorkOrderNumber == "WO-CANCEL").WorkOrderClosed.Should().BeTrue(); } [Fact] public async Task List_MapsDecidedByNameFromTheDecidingUser() { using var context = NewContext(); var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC"); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); context.Users.Add(new ApplicationUser { Id = "user-9", FirstName = "Grace", LastName = "Hopper" }); await context.SaveChangesAsync(); var request = Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)); request.DecidedByUserId = "user-9"; context.DispatchUpliftRequests.Add(request); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None); result.Items.Single().DecidedByName.Should().Be("Grace Hopper"); } // --- Attachment count (SH-207) --- [Fact] public async Task List_CountsOnlyNonDeletedUpliftEvidenceAttachments() { using var context = NewContext(); var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC"); var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); context.VendorCompletionDocuments.AddRange( Document(dispatch, vendor, workOrder, "quote.pdf"), Document(dispatch, vendor, workOrder, "photo.jpg"), Document(dispatch, vendor, workOrder, "signed-completion.pdf", purpose: "Completion"), Document(dispatch, vendor, workOrder, "deleted-estimate.pdf", isDeleted: true)); await context.SaveChangesAsync(); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "Pending", CreatedDate = new DateTime(2026, 3, 2), RequiredTier = 1, RequestedNTE = 200m }); await context.SaveChangesAsync(); var service = NewService(context); var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); result.Items.Single().AttachmentCount.Should().Be(2); } private static VendorCompletionDocument Document( Dispatch dispatch, Vendor vendor, WorkOrder workOrder, string fileName, string purpose = "UpliftEvidence", bool isDeleted = false) => new() { VendorId = vendor.Id, DispatchId = dispatch.Id, WorkOrderId = workOrder.Id, OriginalFileName = fileName, StoredFileName = $"{fileName}.bin", ContentType = "application/octet-stream", SizeBytes = 128, ScanStatus = "Passed", ReviewStatus = "Approved", Purpose = purpose, Version = 1, IsDeleted = isDeleted }; // --- Queue-wide pending exposure (SH-207) --- [Fact] public async Task List_PendingExposureTotal_SumsPendingRequestsAcrossTheQueueRegardlessOfFilter() { using var context = NewContext(); var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "HVAC"); var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); context.DispatchUpliftRequests.AddRange( Request(dispatchA, "Pending", new DateTime(2026, 3, 1), requested: 125m), Request(dispatchB, "Pending", new DateTime(2026, 3, 2), requested: 75m), Request(dispatchA, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 999m), new DispatchUpliftRequest { DispatchId = dispatchA.Id, Status = "Pending", CreatedDate = new DateTime(2026, 3, 5), RequiredTier = 1, RequestedNTE = 500m, IsDeleted = true }); await context.SaveChangesAsync(); var service = NewService(context); // Even when the caller reads the Approved decision log, the header total // still reflects the whole pending queue. var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None); result.PendingExposureTotal.Should().Be(200m); } }