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 { WorkerOrderNumber = 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_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"); item.Delta.Should().Be(300m); item.Status.Should().Be("Pending"); } [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); } // --- 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( Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 300m), 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)); 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_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); } // --- 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); } }