mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-03 10:23:28 +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.
139 lines
6.3 KiB
C#
139 lines
6.3 KiB
C#
using Data.SeaHavenIndustries;
|
|
using Data.SeaHavenIndustries.Enums;
|
|
using Moq;
|
|
using SeaHaven.DataServices.Dto;
|
|
using SeaHaven.DataServices.Exceptions;
|
|
using SeaHaven.DataServices.Interfaces;
|
|
using SeaHaven.Services.Configuration;
|
|
using SeaHaven.Services.DTOs;
|
|
using SeaHaven.Services.Exceptions;
|
|
using SeaHaven.Services.Implementation;
|
|
using SeaHaven.Services.Interfaces;
|
|
using System.Security.Claims;
|
|
using Xunit;
|
|
|
|
namespace Api.SeaHavenIndustries.Tests;
|
|
|
|
public sealed class WorkOrderUpliftServiceConflictTests
|
|
{
|
|
[Fact]
|
|
public async Task CreateAsync_DifferentWorkOrdersSharingDispatch_MapsConcurrentInsertConflict()
|
|
{
|
|
// Dispatch 10 is legitimately shared: work order 1 owns it and work order 2 links to
|
|
// it through DispatchWorkOrders, so the uplift dispatch resolves to it for both.
|
|
var sharedDispatch = new Dispatch
|
|
{
|
|
Id = 10,
|
|
WorkOrderId = 1,
|
|
Status = "Scheduled",
|
|
NTEAmount = 1000m,
|
|
DispatchWorkOrders = new List<DispatchWorkOrder>
|
|
{
|
|
new() { DispatchId = 10, WorkOrderId = 2 },
|
|
},
|
|
};
|
|
var upliftData = new Mock<IUpliftDataService>();
|
|
upliftData.Setup(x => x.GetUpliftDispatchForWorkOrderAsync(
|
|
It.IsIn(1, 2),
|
|
10,
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(sharedDispatch);
|
|
var bothAtInsert = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var savesArrived = 0;
|
|
var winner = 0;
|
|
|
|
upliftData.Setup(x => x.ExecuteWorkOrderMutationAsync(
|
|
It.IsAny<int>(),
|
|
It.IsAny<Func<CancellationToken, Task<WorkOrderUpliftDto?>>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns<int, Func<CancellationToken, Task<WorkOrderUpliftDto?>>, CancellationToken>(
|
|
(_, work, cancellationToken) => work(cancellationToken));
|
|
upliftData.Setup(x => x.HasPendingForWorkOrderAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(false);
|
|
upliftData.Setup(x => x.HasActiveAsync(10, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(false);
|
|
upliftData.Setup(x => x.SumAutoApprovedAmountForWorkOrderAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(0m);
|
|
upliftData.Setup(x => x.StageAsync(It.IsAny<DispatchUpliftRequest>(), It.IsAny<CancellationToken>()))
|
|
.Returns(Task.CompletedTask);
|
|
upliftData.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
|
.Returns(async () =>
|
|
{
|
|
if (Interlocked.Increment(ref savesArrived) == 2)
|
|
bothAtInsert.TrySetResult();
|
|
|
|
await bothAtInsert.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
|
if (Interlocked.Exchange(ref winner, 1) != 0)
|
|
throw new UpliftDispatchConflictException();
|
|
});
|
|
|
|
var detailData = new Mock<IWorkOrderDetailDataService>();
|
|
detailData.Setup(x => x.ExistsAsync(It.IsAny<int>(), It.IsAny<CancellationToken>(), It.IsAny<int?>()))
|
|
.ReturnsAsync(true);
|
|
detailData.Setup(x => x.GetWorkOrderForMediaAsync(It.IsAny<int>(), It.IsAny<CancellationToken>(), It.IsAny<int?>()))
|
|
.ReturnsAsync((int workOrderId, CancellationToken _, int? _) => new WorkOrder
|
|
{
|
|
Id = workOrderId,
|
|
PrimaryDispatchId = 10,
|
|
WorkOrderType = WorkOrderType.PM,
|
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
|
});
|
|
|
|
var dispatchData = new Mock<IDispatchDataService>();
|
|
dispatchData.Setup(x => x.GetByIdAsync(10))
|
|
.ReturnsAsync(sharedDispatch);
|
|
dispatchData.Setup(x => x.StageAuditLogAsync(It.IsAny<WorkOrderAuditLog>(), It.IsAny<CancellationToken>()))
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var accountResolver = new Mock<IWorkOrderAccountResolver>();
|
|
accountResolver.Setup(x => x.ResolveAccountFilter(It.IsAny<ClaimsPrincipal>())).Returns((int?)null);
|
|
var userData = new Mock<IUserDataService>();
|
|
userData.Setup(x => x.GetDisplayNamesByIdsAsync(It.IsAny<IEnumerable<string>>()))
|
|
.ReturnsAsync(new Dictionary<string, string>());
|
|
// SH-327: the requester must hold RequestUplifts before the create reaches the insert.
|
|
var permissionData = new Mock<ITeamPermissionOverrideDataService>();
|
|
permissionData.Setup(x => x.GetUserAsync("dispatcher-1", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new TeamPermissionUserData { UserId = "dispatcher-1", RoleName = "Dispatcher" });
|
|
|
|
var service = new WorkOrderUpliftService(
|
|
upliftData.Object,
|
|
dispatchData.Object,
|
|
detailData.Object,
|
|
accountResolver.Object,
|
|
userData.Object,
|
|
permissionData.Object,
|
|
new TeamPermissionPolicy(),
|
|
TimeProvider.System,
|
|
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions
|
|
{
|
|
UpliftTier1MaxUsd = 2500m,
|
|
Tier1Roles = new[] { "Approver" },
|
|
Tier2Roles = new[] { "Manager" },
|
|
}),
|
|
new Mock<IVendorDocumentDataService>().Object);
|
|
var user = new ClaimsPrincipal(new ClaimsIdentity(
|
|
new[] { new Claim(ClaimTypes.NameIdentifier, "dispatcher-1") },
|
|
"test"));
|
|
|
|
static async Task<(WorkOrderUpliftDto? Result, Exception? Error)> Capture(
|
|
Func<Task<WorkOrderUpliftDto?>> create)
|
|
{
|
|
try
|
|
{
|
|
return (await create(), null);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
return (null, exception);
|
|
}
|
|
}
|
|
|
|
var outcomes = await Task.WhenAll(
|
|
Capture(() => service.CreateAsync(1, new CreateWorkOrderUpliftRequestDto { Amount = 500m }, user, CancellationToken.None)),
|
|
Capture(() => service.CreateAsync(2, new CreateWorkOrderUpliftRequestDto { Amount = 500m }, user, CancellationToken.None)));
|
|
|
|
Assert.Single(outcomes, outcome => outcome.Result != null);
|
|
Assert.IsType<UpliftConflictException>(Assert.Single(outcomes, outcome => outcome.Error != null).Error);
|
|
upliftData.Verify(x => x.HasActiveAsync(10, It.IsAny<CancellationToken>()), Times.Exactly(2));
|
|
}
|
|
}
|