shoc-backend/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs
arthur.bassi 4c15669aff fix(work-orders): enforce SH-196 cumulative allowance and one pending per WO
Auto-approval now uses the WO-scoped $500/$5,000 Emergency cap instead of dispatch NTE, rejects a second open request across dispatches, and cancelling a WO withdraws pending uplifts with audit.
2026-08-14 10:36:35 -03:00

340 lines
12 KiB
C#

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<ApplicationDbContext>()
.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);
}
[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<InvalidOperationException>(() =>
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);
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<InvalidOperationException>(() =>
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);
}
}