using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Helpers; 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 SeaHavenIndustries.Tests; // An uplift's amount is the NTE increase being requested. Work-order requests store // that increase in RequestedNTE; vendor-portal requests store the requested NTE total. // These tests walk each path through the approvals queue, approve and revoke, and the // queue-wide and per-work-order exposure totals. public sealed class UpliftAmountPerCreationPathTests { private const string DispatcherId = "dispatcher-1"; private const int WorkOrderId = 1; private const int PrimaryDispatchId = 10; private const int SecondDispatchId = 11; private static ApplicationDbContext CreateContext() => new(new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); private static ApprovalsOptions NewOptions() => new() { UpliftTier1MaxUsd = 2500m, Tier1Roles = new[] { "Approver" }, Tier2Roles = new[] { "Manager" }, }; private static WorkOrderUpliftService NewWorkOrderUpliftService(ApplicationDbContext context) => new(new UpliftDataService(context), new DispatchDataService(context), new WorkOrderDetailDataService(context), WorkOrderAccountTestHelpers.Resolver(context), new UserDataService(context), new TeamPermissionOverrideDataService(context), new TeamPermissionPolicy(), TimeProvider.System, Options.Create(NewOptions())); private static UpliftService NewQueueService(ApplicationDbContext context) => new(new UpliftDataService(context), new DispatchDataService(context), new NoDocumentStorage(), TimeProvider.System, Options.Create(NewOptions())); private static ClaimsPrincipal Admin(string userId = DispatcherId) => WorkOrderAccountTestHelpers.OrgWideAdmin(userId); private static Dispatch DispatchOf(ApplicationDbContext context, int id) => context.Dispatches.Single(d => d.Id == id); // A work order with a 600 NTE on its primary dispatch, and a second dispatch on the // same work order, also at 600. private static async Task SeedWorkOrderAsync(ApplicationDbContext context) { await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); context.Users.Add(new ApplicationUser { Id = DispatcherId, UserName = DispatcherId, FirstName = "Alex", LastName = "Dispatcher", }); var role = new IdentityRole("Admin"); context.Roles.Add(role); context.UserRoles.Add(new IdentityUserRole { UserId = DispatcherId, RoleId = role.Id }); context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); context.Dispatches.AddRange( new Dispatch { Id = PrimaryDispatchId, VendorId = 1, WorkOrderId = WorkOrderId, NTEAmount = 600m, DispatchNumber = "DIS-10", Status = "Completed", }, new Dispatch { Id = SecondDispatchId, VendorId = 1, WorkOrderId = WorkOrderId, NTEAmount = 600m, DispatchNumber = "DIS-11", Status = "Completed", }); context.workOrders.Add(new WorkOrder { Id = WorkOrderId, InternalWONumber = "10000000001", PrimaryDispatchId = PrimaryDispatchId, AccountId = 1, WorkOrderType = WorkOrderType.PM, }); await context.SaveChangesAsync(); } // Uses up the work order's auto-approval allowance, so a new work-order request // waits for approval instead of auto-approving. private static async Task ConsumeAutoApprovalAllowanceAsync(ApplicationDbContext context) { context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { DispatchId = SecondDispatchId, CurrentNTE = 100m, RequestedNTE = 500m, Status = "NoApprovalRequired", RequiredTier = 0, NotificationStatus = "Sent", createdby = DispatcherId, CreatedDate = new DateTime(2026, 1, 2), }); await context.SaveChangesAsync(); } private static async Task CreatePendingWorkOrderRequestAsync(ApplicationDbContext context, decimal amount) { await ConsumeAutoApprovalAllowanceAsync(context); var created = await NewWorkOrderUpliftService(context).CreateAsync( WorkOrderId, new CreateWorkOrderUpliftRequestDto { Amount = amount, Notes = "Extra parts" }, Admin(), CancellationToken.None); Assert.Equal("pending", created!.Status); return created.Id; } // Stored exactly as the vendor portal writes it: no identity user, and RequestedNTE // is the requested new NTE total. private static async Task SeedVendorRequestAsync( ApplicationDbContext context, int dispatchId, decimal currentNte, decimal requestedTotal) { var request = new DispatchUpliftRequest { DispatchId = dispatchId, CurrentNTE = currentNte, RequestedNTE = requestedTotal, Status = "Pending", RequiredTier = 1, RequestedByVendorName = "Acme HVAC", NotificationStatus = "Sent", CreatedDate = new DateTime(2026, 3, 1), }; context.DispatchUpliftRequests.Add(request); await context.SaveChangesAsync(); return request.Id; } private static Task RevokeAsync(ApplicationDbContext context, int upliftId) => NewWorkOrderUpliftService(context).RevokeAsync( WorkOrderId, upliftId, new RevokeWorkOrderUpliftRequestDto { Reason = "Scope reduced" }, Admin(), CancellationToken.None); [Fact] public async Task WorkOrderRequest_QueueShowsTheRequestedIncrease() { await using var context = CreateContext(); await SeedWorkOrderAsync(context); var upliftId = await CreatePendingWorkOrderRequestAsync(context, 90m); var pending = await NewQueueService(context) .ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None); Assert.Equal(90m, Assert.Single(pending.Items, i => i.Id == upliftId).Delta); } [Fact] public async Task WorkOrderRequest_ApproveAddsIncreaseToNte_RevokeRestoresNte() { await using var context = CreateContext(); await SeedWorkOrderAsync(context); var upliftId = await CreatePendingWorkOrderRequestAsync(context, 90m); var queue = NewQueueService(context); var approved = await queue.ApproveAsync(Admin(), upliftId, null, CancellationToken.None); Assert.Equal(690m, approved.NTEAmount); Assert.Equal(690m, DispatchOf(context, PrimaryDispatchId).NTEAmount); var revoked = await RevokeAsync(context, upliftId); Assert.Equal("revoked", revoked!.Status); Assert.Equal(600m, DispatchOf(context, PrimaryDispatchId).NTEAmount); } [Fact] public async Task VendorRequest_QueueShowsIncrease_ApproveEndsAtRequestedTotal_RevokeRestoresNte() { await using var context = CreateContext(); await SeedWorkOrderAsync(context); var upliftId = await SeedVendorRequestAsync(context, PrimaryDispatchId, currentNte: 600m, requestedTotal: 900m); var queue = NewQueueService(context); var pending = await queue.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None); Assert.Equal(300m, Assert.Single(pending.Items).Delta); var approved = await queue.ApproveAsync(Admin(), upliftId, null, CancellationToken.None); Assert.Equal(900m, approved.NTEAmount); Assert.Equal(900m, DispatchOf(context, PrimaryDispatchId).NTEAmount); var revoked = await RevokeAsync(context, upliftId); Assert.Equal("revoked", revoked!.Status); Assert.Equal(600m, DispatchOf(context, PrimaryDispatchId).NTEAmount); } [Fact] public async Task PendingExposureAndApprovedOnWorkOrder_SumEachRequestsIncrease() { await using var context = CreateContext(); await SeedWorkOrderAsync(context); var workOrderRequestId = await CreatePendingWorkOrderRequestAsync(context, 90m); var vendorRequestId = await SeedVendorRequestAsync(context, SecondDispatchId, currentNte: 600m, requestedTotal: 900m); var queue = NewQueueService(context); var pending = await queue.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None); Assert.Equal(390m, pending.PendingExposureTotal); Assert.Equal(pending.Items.Sum(i => i.Delta), pending.PendingExposureTotal); await queue.ApproveAsync(Admin(), workOrderRequestId, null, CancellationToken.None); await queue.ApproveAsync(Admin(), vendorRequestId, null, CancellationToken.None); var decided = await queue.ListAsync(Admin(), "Approved", null, 1, 25, CancellationToken.None); var row = Assert.Single(decided.Items, i => i.Id == workOrderRequestId); Assert.Equal(390m, row.WorkOrderAdminApprovedTotal); // The earlier auto-approved work-order request counts its stored increase. Assert.Equal(500m, row.WorkOrderAutoApprovedTotal); Assert.Equal(0m, (await queue.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None)).PendingExposureTotal); } [Fact] public async Task AutoApprovedWorkOrderRequest_ConsumesAllowanceByItsRequestedIncrease() { await using var context = CreateContext(); await SeedWorkOrderAsync(context); var created = await NewWorkOrderUpliftService(context).CreateAsync( WorkOrderId, new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Within limit" }, Admin(), CancellationToken.None); Assert.Equal("auto_approved", created!.Status); var stored = context.DispatchUpliftRequests.Single(u => u.Id == created.Id); Assert.Equal(DispatcherId, stored.createdby); Assert.Equal(400m, stored.RequestedNTE); Assert.Equal(1000m, DispatchOf(context, PrimaryDispatchId).NTEAmount); Assert.Equal(400m, await new UpliftDataService(context) .SumAutoApprovedAmountForWorkOrderAsync(WorkOrderId, CancellationToken.None)); } [Fact] public async Task WorkOrderUpliftList_ShowsEachRequestsIncrease() { await using var context = CreateContext(); await SeedWorkOrderAsync(context); var workOrderRequestId = await CreatePendingWorkOrderRequestAsync(context, 90m); var vendorRequestId = await SeedVendorRequestAsync(context, SecondDispatchId, currentNte: 600m, requestedTotal: 900m); var uplifts = await NewWorkOrderUpliftService(context) .ListAsync(WorkOrderId, Admin(), CancellationToken.None); Assert.Equal(90m, Assert.Single(uplifts!, u => u.Id == workOrderRequestId).Amount); Assert.Equal(300m, Assert.Single(uplifts!, u => u.Id == vendorRequestId).Amount); } [Theory] [InlineData(DispatcherId, 90, "$90.00")] [InlineData(null, 900, "$300.00")] public void NotificationDeltaRow_ShowsTheRequestedIncrease(string? createdBy, int requestedNte, string expectedDelta) { var dispatch = new Dispatch { Id = PrimaryDispatchId, DispatchNumber = "DIS-10", NTEAmount = 600m }; var request = new DispatchUpliftRequest { DispatchId = PrimaryDispatchId, CurrentNTE = 600m, RequestedNTE = requestedNte, RequiredTier = 1, createdby = createdBy, }; // Builds the message content only; nothing is sent. var (_, body) = UpliftNotificationMessage.BuildInitial(dispatch, request, "Acme HVAC", "https://example.test"); Assert.Contains( $"Delta{expectedDelta}", body); } [Fact] public void AmountDefinition_TranslatesToSqlServer_ForRowAndGroupedSums() { // ToQueryString only builds SQL; it never opens the connection. using var context = new ApplicationDbContext(new DbContextOptionsBuilder() .UseSqlServer("Server=translation-only;Database=none;Trusted_Connection=True;") .Options); var rowSql = context.DispatchUpliftRequests .Select(UpliftAmount.ForQuery) .ToQueryString(); var groupedSql = context.DispatchUpliftRequests .GroupBy(u => new { u.DispatchId, u.Status }, UpliftAmount.ForQuery) .Select(g => new { g.Key.DispatchId, g.Key.Status, Total = g.Sum() }) .ToQueryString(); Assert.Contains("CASE", rowSql); Assert.Contains("[createdby] IS NULL", rowSql); Assert.Contains("SUM(CASE", groupedSql); Assert.Contains("GROUP BY", groupedSql); } private sealed class NoDocumentStorage : IVendorDocumentStoragePort { public Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken) => Task.CompletedTask; public Stream OpenRead(int vendorId, int dispatchId, string storedFileName) => throw new NotSupportedException(); public void Delete(int vendorId, int dispatchId, string storedFileName) { } } }