using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; 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; public sealed class WorkOrderUpliftServiceTests { private static ApplicationDbContext CreateContext() { 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" }, }; private static WorkOrderUpliftService NewService(ApplicationDbContext context) { return new WorkOrderUpliftService( new UpliftDataService(context), new DispatchDataService(context), new WorkOrderDetailDataService(context), WorkOrderAccountTestHelpers.Resolver(context), new UserDataService(context), TimeProvider.System, Options.Create(NewOptions())); } private static ClaimsPrincipal Dispatcher(string userId = "dispatcher-1") => WorkOrderAccountTestHelpers.OrgWideAdmin(userId); private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync( ApplicationDbContext context, WorkOrderType type = WorkOrderType.PM) { await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); context.Users.Add(new ApplicationUser { Id = "dispatcher-1", UserName = "dispatcher-1", FirstName = "Alex", LastName = "Dispatcher", }); context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); context.Dispatches.Add(new Dispatch { Id = 10, VendorId = 1, WorkOrderId = 1, NTEAmount = 1000m, DispatchNumber = "DIS-10", Status = "Completed", }); var workOrder = new WorkOrder { Id = 1, InternalWONumber = "10000000001", PrimaryDispatchId = 10, AccountId = 1, WorkOrderType = type, }; context.workOrders.Add(workOrder); await context.SaveChangesAsync(); return (workOrder, context.Dispatches.Single(d => d.Id == 10)); } [Fact] public async Task ListAsync_ReturnsWorkOrderScopedUplifts() { await using var context = CreateContext(); var (workOrder, dispatch) = await SeedWorkOrderAsync(context); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { Id = 100, DispatchId = dispatch.Id, RequestedNTE = 1500m, Status = "Pending", RequiredTier = 1, NotificationStatus = "Pending", VendorReason = "Extra coil work", }); await context.SaveChangesAsync(); var service = NewService(context); var items = await service.ListAsync(workOrder.Id, Dispatcher(), CancellationToken.None); Assert.NotNull(items); var uplift = Assert.Single(items!); Assert.Equal("pending", uplift.Status); Assert.Equal(1500m, uplift.Amount); } [Fact] public async Task CreateAsync_PmAmountAtCap_CreatesAutoApprovedRequest() { await using var context = CreateContext(); var (workOrder, _) = await SeedWorkOrderAsync(context); var service = NewService(context); var created = await service.CreateAsync( workOrder.Id, new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Within limit" }, Dispatcher(), CancellationToken.None); Assert.NotNull(created); Assert.Equal("auto_approved", created!.Status); Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); } [Fact] public async Task CreateAsync_PmAmountAboveCap_CreatesPendingRequest() { await using var context = CreateContext(); var (workOrder, _) = await SeedWorkOrderAsync(context); var service = NewService(context); var created = await service.CreateAsync( workOrder.Id, new CreateWorkOrderUpliftRequestDto { Amount = 501m, Notes = "Parts" }, Dispatcher(), CancellationToken.None); Assert.NotNull(created); Assert.Equal("pending", created!.Status); Assert.Equal("dispatcher-1", created.RequestedById); Assert.Equal("Pending", Assert.Single(context.DispatchUpliftRequests).Status); } [Fact] public async Task CreateAsync_PmSecondRequestExceedsRemainingAllowance_CreatesPending() { await using var context = CreateContext(); var (workOrder, _) = await SeedWorkOrderAsync(context); var service = NewService(context); var first = await service.CreateAsync( workOrder.Id, new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "First" }, Dispatcher(), CancellationToken.None); Assert.Equal("auto_approved", first!.Status); var second = await service.CreateAsync( workOrder.Id, new CreateWorkOrderUpliftRequestDto { Amount = 200m, Notes = "Second" }, Dispatcher(), CancellationToken.None); Assert.Equal("pending", second!.Status); } [Fact] public async Task CreateAsync_EmergencyAtCap_CreatesAutoApprovedRequest() { await using var context = CreateContext(); var (workOrder, _) = await SeedWorkOrderAsync(context, WorkOrderType.Emergency); var service = NewService(context); var created = await service.CreateAsync( workOrder.Id, new CreateWorkOrderUpliftRequestDto { Amount = 5000m, Notes = "Emergency parts" }, Dispatcher(), CancellationToken.None); Assert.Equal("auto_approved", created!.Status); } [Fact] public async Task CreateAsync_EmergencyAboveCap_CreatesPendingRequest() { await using var context = CreateContext(); var (workOrder, _) = await SeedWorkOrderAsync(context, WorkOrderType.Emergency); var service = NewService(context); var created = await service.CreateAsync( workOrder.Id, new CreateWorkOrderUpliftRequestDto { Amount = 5001m, Notes = "Above emergency cap" }, Dispatcher(), CancellationToken.None); Assert.Equal("pending", created!.Status); } [Fact] public async Task CreateAsync_PendingOnOtherDispatch_BlocksNewRequest() { await using var context = CreateContext(); var (workOrder, _) = await SeedWorkOrderAsync(context); context.Dispatches.Add(new Dispatch { Id = 11, VendorId = 1, WorkOrderId = workOrder.Id, NTEAmount = 800m, DispatchNumber = "DIS-11", Status = "Scheduled", }); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { Id = 100, DispatchId = 11, RequestedNTE = 300m, Status = "Pending", RequiredTier = 1, NotificationStatus = "Pending", }); await context.SaveChangesAsync(); var service = NewService(context); var ex = await Assert.ThrowsAsync(() => service.CreateAsync( workOrder.Id, new CreateWorkOrderUpliftRequestDto { Amount = 100m, Notes = "Second open" }, Dispatcher(), CancellationToken.None)); Assert.Contains("open uplift request", ex.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task WithdrawPendingForWorkOrderAsync_CancelsOpenRequestsAndAudits() { await using var context = CreateContext(); var (workOrder, dispatch) = await SeedWorkOrderAsync(context); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { Id = 100, DispatchId = dispatch.Id, RequestedNTE = 600m, Status = "Pending", RequiredTier = 1, NotificationStatus = "Pending", createdby = "dispatcher-1", }); await context.SaveChangesAsync(); var service = NewService(context); await service.WithdrawPendingForWorkOrderAsync(workOrder.Id, "actor-1", CancellationToken.None); await context.SaveChangesAsync(); Assert.Equal("Withdrawn", Assert.Single(context.DispatchUpliftRequests).Status); Assert.Contains(context.WorkOrderAuditLogs, log => log.Action == "uplift_cancel"); } [Fact] public async Task CancelAsync_PendingRequest_Withdraws() { await using var context = CreateContext(); var (workOrder, dispatch) = await SeedWorkOrderAsync(context); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { Id = 100, DispatchId = dispatch.Id, RequestedNTE = 1500m, Status = "Pending", RequiredTier = 1, NotificationStatus = "Pending", createdby = "dispatcher-1", }); await context.SaveChangesAsync(); var service = NewService(context); var cancelled = await service.CancelAsync(workOrder.Id, 100, Dispatcher(), CancellationToken.None); Assert.NotNull(cancelled); Assert.Equal("cancelled", cancelled!.Status); } [Fact] public async Task RevokeAsync_ApprovedRequiresAdminReason() { await using var context = CreateContext(); var (workOrder, dispatch) = await SeedWorkOrderAsync(context); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { Id = 100, DispatchId = dispatch.Id, RequestedNTE = 1500m, CurrentNTE = 1000m, Status = "Approved", RequiredTier = 1, NotificationStatus = "Sent", }); await context.SaveChangesAsync(); var service = NewService(context); await Assert.ThrowsAsync(() => service.RevokeAsync( workOrder.Id, 100, new RevokeWorkOrderUpliftRequestDto(), Dispatcher("dispatcher-1"), CancellationToken.None)); } [Fact] public async Task RevokeAsync_AdminRevokesApproved_ReturnsRevokedStatus() { await using var context = CreateContext(); var (workOrder, dispatch) = await SeedWorkOrderAsync(context); dispatch.NTEAmount = 1500m; context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { Id = 100, DispatchId = dispatch.Id, RequestedNTE = 1500m, CurrentNTE = 1000m, Status = "Approved", RequiredTier = 1, NotificationStatus = "Sent", }); await context.SaveChangesAsync(); var service = NewService(context); var revoked = await service.RevokeAsync( workOrder.Id, 100, new RevokeWorkOrderUpliftRequestDto { Reason = "Policy change" }, WorkOrderAccountTestHelpers.OrgWideAdmin("admin-1"), CancellationToken.None); Assert.NotNull(revoked); Assert.Equal("revoked", revoked!.Status); Assert.Equal(1000m, dispatch.NTEAmount); } [Fact] public async Task RevokeAsync_AutoApproved_RestoresNteAndFreesAllowance() { await using var context = CreateContext(); var (workOrder, dispatch) = await SeedWorkOrderAsync(context); var service = NewService(context); // Auto-approved create raises the dispatch NTE by the requested amount. var created = await service.CreateAsync( workOrder.Id, new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Within limit" }, Dispatcher(), CancellationToken.None); Assert.Equal("auto_approved", created!.Status); Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); var revoked = await service.RevokeAsync( workOrder.Id, created.Id, new RevokeWorkOrderUpliftRequestDto(), Dispatcher(), CancellationToken.None); // SH-196: revoking frees the allowance, so it must release the NTE too. Otherwise // every create -> revoke cycle compounds NTE inflation. Assert.Equal("revoked", revoked!.Status); Assert.Equal(1000m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); // The freed allowance is reusable at the full cap. var second = await service.CreateAsync( workOrder.Id, new CreateWorkOrderUpliftRequestDto { Amount = 500m, Notes = "Reuses freed allowance" }, Dispatcher(), CancellationToken.None); Assert.Equal("auto_approved", second!.Status); Assert.Equal(1500m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); } [Theory] [InlineData(LifecycleStatus.Completed)] [InlineData(LifecycleStatus.Canceled)] public async Task RevokeAsync_TerminalWorkOrder_IsRejected(LifecycleStatus lifecycle) { await using var context = CreateContext(); var (workOrder, dispatch) = await SeedWorkOrderAsync(context); dispatch.NTEAmount = 1500m; context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { Id = 100, DispatchId = dispatch.Id, RequestedNTE = 1500m, CurrentNTE = 1000m, Status = "Approved", RequiredTier = 1, NotificationStatus = "Sent", }); workOrder.LifecycleStatus = lifecycle; await context.SaveChangesAsync(); var service = NewService(context); // SH-196: the board dialog's read-only state is UX only; a direct API call must // still be rejected server-side. await Assert.ThrowsAsync(() => service.RevokeAsync( workOrder.Id, 100, new RevokeWorkOrderUpliftRequestDto { Reason = "Policy change" }, WorkOrderAccountTestHelpers.OrgWideAdmin("admin-1"), CancellationToken.None)); Assert.Equal(1500m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); Assert.Equal("Approved", context.DispatchUpliftRequests.Single(r => r.Id == 100).Status); } [Theory] [InlineData(LifecycleStatus.Completed)] [InlineData(LifecycleStatus.Canceled)] public async Task CancelAsync_TerminalWorkOrder_IsRejected(LifecycleStatus lifecycle) { await using var context = CreateContext(); var (workOrder, dispatch) = await SeedWorkOrderAsync(context); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { Id = 101, DispatchId = dispatch.Id, RequestedNTE = 1600m, CurrentNTE = 1000m, Status = "Pending", RequiredTier = 1, NotificationStatus = "Sent", }); workOrder.LifecycleStatus = lifecycle; await context.SaveChangesAsync(); var service = NewService(context); await Assert.ThrowsAsync(() => service.CancelAsync( workOrder.Id, 101, Dispatcher(), CancellationToken.None)); Assert.Equal("Pending", context.DispatchUpliftRequests.Single(r => r.Id == 101).Status); } [Fact] public async Task CreateAsync_ConcurrentRequests_PreserveOnePendingAndCap() { var databaseName = Guid.NewGuid().ToString(); var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName) .Options; await using (var seed = new ApplicationDbContext(options)) { await SeedWorkOrderAsync(seed); } await using var firstContext = new ApplicationDbContext(options); await using var secondContext = new ApplicationDbContext(options); var first = NewService(firstContext); var second = NewService(secondContext); var request = new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Concurrent" }; var results = await Task.WhenAll( first.CreateAsync(1, request, Dispatcher(), CancellationToken.None), second.CreateAsync(1, request, Dispatcher(), CancellationToken.None)); await using var verify = new ApplicationDbContext(options); var rows = verify.DispatchUpliftRequests.ToList(); var autoApproved = rows.Where(row => row.Status == "NoApprovalRequired").ToList(); var pending = rows.Where(row => row.Status == "Pending").ToList(); Assert.Equal(2, results.Length); Assert.True(results.All(result => result != null)); Assert.Single(autoApproved); Assert.Single(pending); Assert.True(autoApproved.Sum(row => row.RequestedNTE) <= 500m); } [Fact] public async Task RevokeAsync_OwnerRevokesAutoApproved_RestoresNteAndAllowance() { await using var context = CreateContext(); var (workOrder, _) = await SeedWorkOrderAsync(context); var service = NewService(context); var created = await service.CreateAsync( workOrder.Id, new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Within limit" }, Dispatcher(), CancellationToken.None); Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); var revoked = await service.RevokeAsync( workOrder.Id, created!.Id, new RevokeWorkOrderUpliftRequestDto(), Dispatcher(), CancellationToken.None); Assert.Equal("revoked", revoked!.Status); Assert.Equal(1000m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); Assert.Equal(0m, await new UpliftDataService(context) .SumAutoApprovedAmountForWorkOrderAsync(workOrder.Id, CancellationToken.None)); } [Theory] [InlineData(LifecycleStatus.Completed)] [InlineData(LifecycleStatus.Canceled)] public async Task RevokeAsync_TerminalWorkOrder_Throws(LifecycleStatus status) { await using var context = CreateContext(); var (workOrder, dispatch) = await SeedWorkOrderAsync(context); workOrder.LifecycleStatus = status; context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { Id = 100, DispatchId = dispatch.Id, RequestedNTE = 400m, CurrentNTE = 1000m, Status = "NoApprovalRequired", RequiredTier = 0, NotificationStatus = "Sent", createdby = "dispatcher-1", }); await context.SaveChangesAsync(); var service = NewService(context); var ex = await Assert.ThrowsAsync(() => service.RevokeAsync( workOrder.Id, 100, new RevokeWorkOrderUpliftRequestDto(), Dispatcher(), CancellationToken.None)); Assert.Contains("work order", ex.Message, StringComparison.OrdinalIgnoreCase); } [Theory] [InlineData(LifecycleStatus.Completed)] [InlineData(LifecycleStatus.Canceled)] public async Task CancelAsync_TerminalWorkOrder_Throws(LifecycleStatus status) { await using var context = CreateContext(); var (workOrder, dispatch) = await SeedWorkOrderAsync(context); workOrder.LifecycleStatus = status; context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { Id = 100, DispatchId = dispatch.Id, RequestedNTE = 600m, Status = "Pending", RequiredTier = 1, NotificationStatus = "Pending", }); await context.SaveChangesAsync(); var service = NewService(context); var ex = await Assert.ThrowsAsync(() => service.CancelAsync(workOrder.Id, 100, Dispatcher(), CancellationToken.None)); Assert.Contains("work order", ex.Message, StringComparison.OrdinalIgnoreCase); } }