mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-04 20:42:09 +00:00
The route contract keeps the poc endpoint from main and the uplift evidence routes from this branch. Create still passes the scanned evidence document through the locked mutation.
933 lines
35 KiB
C#
933 lines
35 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()),
|
|
new VendorDocumentDataService(context));
|
|
}
|
|
|
|
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.Null(created.EvidenceDocumentId);
|
|
Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
|
|
Assert.Null(Assert.Single(context.DispatchUpliftRequests).EvidenceDocumentId);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_WorkOrderCancelledBySyncStatusText_IsRefusedAndCreatesNothing()
|
|
{
|
|
await using var context = CreateContext();
|
|
var (workOrder, _) = await SeedWorkOrderAsync(context);
|
|
// The legacy ingest cancels a work order by its status text alone.
|
|
workOrder.Status = "Cancelled";
|
|
await context.SaveChangesAsync();
|
|
var service = NewService(context);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => service.CreateAsync(
|
|
workOrder.Id,
|
|
new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "After cancel" },
|
|
Dispatcher(),
|
|
CancellationToken.None));
|
|
|
|
Assert.Empty(context.DispatchUpliftRequests);
|
|
Assert.Equal(1000m, 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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_PassedEvidenceOnSameDispatch_LinksDocument()
|
|
{
|
|
await using var context = CreateContext();
|
|
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
|
|
var document = SeedEvidence(context, dispatch.Id, dispatch.VendorId, workOrder.Id);
|
|
await context.SaveChangesAsync();
|
|
var service = NewService(context);
|
|
|
|
var created = await service.CreateAsync(
|
|
workOrder.Id,
|
|
new CreateWorkOrderUpliftRequestDto
|
|
{
|
|
Amount = 400m,
|
|
Notes = "Quote",
|
|
EvidenceDocumentId = document.Id,
|
|
},
|
|
Dispatcher(),
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(document.Id, created!.EvidenceDocumentId);
|
|
Assert.Equal(document.Id, Assert.Single(context.DispatchUpliftRequests).EvidenceDocumentId);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("other-dispatch")]
|
|
[InlineData("wrong-purpose")]
|
|
[InlineData("pending-scan")]
|
|
public async Task CreateAsync_EvidenceThatDoesNotQualify_RejectsWithoutSaving(string defect)
|
|
{
|
|
await using var context = CreateContext();
|
|
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
|
|
var document = defect switch
|
|
{
|
|
"other-dispatch" => SeedEvidence(context, dispatchId: 99, dispatch.VendorId, workOrder.Id),
|
|
"wrong-purpose" => SeedEvidence(context, dispatch.Id, dispatch.VendorId, workOrder.Id, purpose: "Completion"),
|
|
_ => SeedEvidence(context, dispatch.Id, dispatch.VendorId, workOrder.Id, scanStatus: "Pending"),
|
|
};
|
|
await context.SaveChangesAsync();
|
|
var service = NewService(context);
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
service.CreateAsync(
|
|
workOrder.Id,
|
|
new CreateWorkOrderUpliftRequestDto
|
|
{
|
|
Amount = 400m,
|
|
Notes = "Quote",
|
|
EvidenceDocumentId = document.Id,
|
|
},
|
|
Dispatcher(),
|
|
CancellationToken.None));
|
|
|
|
Assert.Contains("not available", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Empty(context.DispatchUpliftRequests);
|
|
}
|
|
|
|
private static VendorCompletionDocument SeedEvidence(
|
|
ApplicationDbContext context,
|
|
int dispatchId,
|
|
int vendorId,
|
|
int workOrderId,
|
|
string scanStatus = "Passed",
|
|
string purpose = "UpliftEvidence")
|
|
{
|
|
var document = new VendorCompletionDocument
|
|
{
|
|
Id = 22,
|
|
VendorId = vendorId,
|
|
DispatchId = dispatchId,
|
|
WorkOrderId = workOrderId,
|
|
OriginalFileName = "quote.pdf",
|
|
StoredFileName = "stored.pdf",
|
|
ContentType = "application/pdf",
|
|
SizeBytes = 4,
|
|
ScanStatus = scanStatus,
|
|
ReviewStatus = "Processing",
|
|
Purpose = purpose,
|
|
Version = 1,
|
|
};
|
|
context.VendorCompletionDocuments.Add(document);
|
|
return document;
|
|
}
|
|
}
|