mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
An admin revoke overturns a human decision, so admins may revoke only admin-approved uplifts. The work-order revoke path let an admin revoke an auto-approved uplift they had requested themselves. Both revoke endpoints now refuse it; dispatchers keep revoking their own auto-approved uplifts.
826 lines
31 KiB
C#
826 lines
31 KiB
C#
using Data.SeaHavenIndustries;
|
|
using Data.SeaHavenIndustries.Enums;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
using SeaHaven.DataServices.Implementation;
|
|
using SeaHaven.Services.Configuration;
|
|
using SeaHaven.Services.Constants;
|
|
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),
|
|
new TeamPermissionOverrideDataService(context),
|
|
new TeamPermissionPolicy(),
|
|
TimeProvider.System,
|
|
Options.Create(NewOptions()));
|
|
}
|
|
|
|
private static ClaimsPrincipal Dispatcher(string userId = "dispatcher-1")
|
|
=> WorkOrderAccountTestHelpers.OrgWideAdmin(userId);
|
|
|
|
// Same user as Dispatcher(), without the Admin role: the only kind of caller that
|
|
// may revoke an auto-approved uplift, and only their own.
|
|
private static ClaimsPrincipal DispatcherRoleOnly(string userId = "dispatcher-1")
|
|
=> WorkOrderAccountTestHelpers.AccountUser(userId, 1, "Dispatcher");
|
|
|
|
private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync(
|
|
ApplicationDbContext context,
|
|
WorkOrderType type = WorkOrderType.PM,
|
|
string role = "Admin",
|
|
int? primaryDispatchId = 10)
|
|
{
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
|
|
context.Users.Add(new ApplicationUser
|
|
{
|
|
Id = "dispatcher-1",
|
|
UserName = "dispatcher-1",
|
|
FirstName = "Alex",
|
|
LastName = "Dispatcher",
|
|
});
|
|
var identityRole = new IdentityRole(role);
|
|
context.Roles.Add(identityRole);
|
|
context.UserRoles.Add(new IdentityUserRole<string>
|
|
{
|
|
UserId = "dispatcher-1",
|
|
RoleId = identityRole.Id,
|
|
});
|
|
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 = primaryDispatchId,
|
|
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_CurrentDatabaseAdmin_RemainsUnrestricted()
|
|
{
|
|
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 },
|
|
WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, "Scheduler"),
|
|
CancellationToken.None);
|
|
|
|
Assert.NotNull(created);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_WithoutPrimaryDispatch_UsesWorkOrderDispatch()
|
|
{
|
|
await using var context = CreateContext();
|
|
var (workOrder, dispatch) = await SeedWorkOrderAsync(context, primaryDispatchId: null);
|
|
var service = NewService(context);
|
|
|
|
var created = await service.CreateAsync(
|
|
workOrder.Id,
|
|
new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Missing primary" },
|
|
Dispatcher(),
|
|
CancellationToken.None);
|
|
|
|
Assert.NotNull(created);
|
|
Assert.Equal("auto_approved", created!.Status);
|
|
Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == dispatch.Id).NTEAmount);
|
|
Assert.Equal(1, await context.DispatchUpliftRequests.CountAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_WithoutPrimaryDispatch_AuditsRequestedWorkOrderWhenDispatchBelongsToAnother()
|
|
{
|
|
await using var context = CreateContext();
|
|
var (workOrder, dispatch) = await SeedWorkOrderAsync(context, primaryDispatchId: null);
|
|
context.workOrders.Add(new WorkOrder
|
|
{
|
|
Id = 2,
|
|
InternalWONumber = "10000000002",
|
|
AccountId = 1,
|
|
WorkOrderType = WorkOrderType.PM,
|
|
});
|
|
dispatch.WorkOrderId = 2;
|
|
context.DispatchWorkOrders.Add(new DispatchWorkOrder
|
|
{
|
|
DispatchId = dispatch.Id,
|
|
WorkOrderId = workOrder.Id,
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var service = NewService(context);
|
|
await service.CreateAsync(
|
|
workOrder.Id,
|
|
new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Associated dispatch" },
|
|
Dispatcher(),
|
|
CancellationToken.None);
|
|
|
|
var audit = Assert.Single(context.WorkOrderAuditLogs);
|
|
Assert.Equal(workOrder.Id, audit.WorkOrderId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_StaleAdminClaim_DeniesWhenDatabaseRoleIsNotAdmin()
|
|
{
|
|
await using var context = CreateContext();
|
|
var (workOrder, dispatch) = await SeedWorkOrderAsync(context, role: "Scheduler");
|
|
var service = NewService(context);
|
|
|
|
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(() => service.CreateAsync(
|
|
workOrder.Id,
|
|
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
|
|
Dispatcher(),
|
|
CancellationToken.None));
|
|
|
|
Assert.Equal(UpliftForbiddenException.RequestUpliftsDeniedMessage, exception.Message);
|
|
Assert.Empty(context.DispatchUpliftRequests);
|
|
Assert.Equal(1000m, dispatch.NTEAmount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_MissingDatabaseUser_DeniesEvenWithAdminClaim()
|
|
{
|
|
await using var context = CreateContext();
|
|
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
|
|
context.UserRoles.RemoveRange(context.UserRoles.Where(userRole => userRole.UserId == "dispatcher-1"));
|
|
context.Users.Remove(context.Users.Single(user => user.Id == "dispatcher-1"));
|
|
await context.SaveChangesAsync();
|
|
var service = NewService(context);
|
|
|
|
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(() => service.CreateAsync(
|
|
workOrder.Id,
|
|
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
|
|
Dispatcher("dispatcher-1"),
|
|
CancellationToken.None));
|
|
|
|
Assert.Equal(UpliftForbiddenException.RequestUpliftsDeniedMessage, exception.Message);
|
|
Assert.Empty(context.DispatchUpliftRequests);
|
|
Assert.Equal(1000m, dispatch.NTEAmount);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Dispatcher", true)]
|
|
[InlineData("Scheduler", false)]
|
|
public async Task CreateAsync_UsesRoleDefaultForRequestPermission(string role, bool allowed)
|
|
{
|
|
await using var context = CreateContext();
|
|
var (workOrder, dispatch) = await SeedWorkOrderAsync(context, role: role);
|
|
var service = NewService(context);
|
|
|
|
var act = () => service.CreateAsync(
|
|
workOrder.Id,
|
|
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
|
|
WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, role),
|
|
CancellationToken.None);
|
|
|
|
if (allowed)
|
|
{
|
|
Assert.NotNull(await act());
|
|
return;
|
|
}
|
|
|
|
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(act);
|
|
Assert.Equal("Your role can't request uplifts on this work order.", exception.Message);
|
|
Assert.Empty(context.DispatchUpliftRequests);
|
|
Assert.Equal(1000m, dispatch.NTEAmount);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Scheduler", UserPermissionState.Allow, true)]
|
|
[InlineData("Dispatcher", UserPermissionState.Deny, false)]
|
|
public async Task CreateAsync_AppliesExplicitRequestPermissionOverride(
|
|
string role,
|
|
UserPermissionState overrideState,
|
|
bool allowed)
|
|
{
|
|
await using var context = CreateContext();
|
|
var (workOrder, dispatch) = await SeedWorkOrderAsync(context, role: role);
|
|
context.UserPermissionOverrides.Add(new UserPermissionOverride
|
|
{
|
|
UserId = "dispatcher-1",
|
|
PermissionKey = TeamPermissionKeys.RequestUplifts,
|
|
State = overrideState,
|
|
});
|
|
await context.SaveChangesAsync();
|
|
var service = NewService(context);
|
|
|
|
var act = () => service.CreateAsync(
|
|
workOrder.Id,
|
|
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
|
|
WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, role),
|
|
CancellationToken.None);
|
|
|
|
if (allowed)
|
|
{
|
|
Assert.NotNull(await act());
|
|
return;
|
|
}
|
|
|
|
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(act);
|
|
Assert.Equal("Your role can't request uplifts on this work order.", exception.Message);
|
|
Assert.Empty(context.DispatchUpliftRequests);
|
|
Assert.Equal(1000m, dispatch.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<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 CreateAsync_ActiveRequestOnPrimaryDispatch_BlocksDuplicate()
|
|
{
|
|
await using var context = CreateContext();
|
|
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
|
|
// SH-393: an unlinked primary is no longer an uplift target, so the shared-dispatch
|
|
// case (owned by another work order, linked to this one) carries the duplicate check.
|
|
context.workOrders.Add(new WorkOrder
|
|
{
|
|
Id = 2,
|
|
InternalWONumber = "10000000002",
|
|
AccountId = 1,
|
|
WorkOrderType = WorkOrderType.PM,
|
|
});
|
|
dispatch.WorkOrderId = 2;
|
|
context.DispatchWorkOrders.Add(new DispatchWorkOrder
|
|
{
|
|
DispatchId = dispatch.Id,
|
|
WorkOrderId = workOrder.Id,
|
|
});
|
|
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
|
{
|
|
Id = 100,
|
|
DispatchId = dispatch.Id,
|
|
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 = "Duplicate open" },
|
|
Dispatcher(),
|
|
CancellationToken.None));
|
|
|
|
Assert.Contains("open uplift request", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Single(context.DispatchUpliftRequests);
|
|
}
|
|
|
|
[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<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);
|
|
}
|
|
|
|
[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(),
|
|
DispatcherRoleOnly(),
|
|
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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<ApplicationDbContext>()
|
|
.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(),
|
|
DispatcherRoleOnly(),
|
|
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));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevokeAsync_AdminOwnerRevokingAutoApproved_IsForbiddenAndKeepsAllowanceConsumed()
|
|
{
|
|
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("auto_approved", created!.Status);
|
|
|
|
await Assert.ThrowsAsync<UpliftForbiddenException>(() => service.RevokeAsync(
|
|
workOrder.Id,
|
|
created.Id,
|
|
new RevokeWorkOrderUpliftRequestDto { Reason = "Wrong quote" },
|
|
Dispatcher(),
|
|
CancellationToken.None));
|
|
|
|
var stored = context.DispatchUpliftRequests.Single(u => u.Id == created.Id);
|
|
Assert.Equal(UpliftStatus.NoApprovalRequired, stored.Status);
|
|
Assert.Null(stored.DecisionNote);
|
|
Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
|
|
Assert.Equal(400m, 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<InvalidOperationException>(() =>
|
|
service.RevokeAsync(
|
|
workOrder.Id,
|
|
100,
|
|
new RevokeWorkOrderUpliftRequestDto(),
|
|
DispatcherRoleOnly(),
|
|
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<InvalidOperationException>(() =>
|
|
service.CancelAsync(workOrder.Id, 100, Dispatcher(), CancellationToken.None));
|
|
Assert.Contains("work order", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
}
|