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 { new() { DispatchId = 10, WorkOrderId = 2 }, }, }; var upliftData = new Mock(); upliftData.Setup(x => x.GetUpliftDispatchForWorkOrderAsync( It.IsIn(1, 2), 10, It.IsAny())) .ReturnsAsync(sharedDispatch); var bothAtInsert = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var savesArrived = 0; var winner = 0; upliftData.Setup(x => x.ExecuteWorkOrderMutationAsync( It.IsAny(), It.IsAny>>(), It.IsAny())) .Returns>, CancellationToken>( (_, work, cancellationToken) => work(cancellationToken)); upliftData.Setup(x => x.HasPendingForWorkOrderAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(false); upliftData.Setup(x => x.HasActiveAsync(10, It.IsAny())) .ReturnsAsync(false); upliftData.Setup(x => x.SumAutoApprovedAmountForWorkOrderAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(0m); upliftData.Setup(x => x.StageAsync(It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); upliftData.Setup(x => x.SaveChangesAsync(It.IsAny())) .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(); detailData.Setup(x => x.ExistsAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(true); detailData.Setup(x => x.GetWorkOrderForMediaAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((int workOrderId, CancellationToken _, int? _) => new WorkOrder { Id = workOrderId, PrimaryDispatchId = 10, WorkOrderType = WorkOrderType.PM, LifecycleStatus = LifecycleStatus.Scheduled, }); var dispatchData = new Mock(); dispatchData.Setup(x => x.GetByIdAsync(10)) .ReturnsAsync(sharedDispatch); dispatchData.Setup(x => x.StageAuditLogAsync(It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var accountResolver = new Mock(); accountResolver.Setup(x => x.ResolveAccountFilter(It.IsAny())).Returns((int?)null); var userData = new Mock(); userData.Setup(x => x.GetDisplayNamesByIdsAsync(It.IsAny>())) .ReturnsAsync(new Dictionary()); // SH-327: the requester must hold RequestUplifts before the create reaches the insert. var permissionData = new Mock(); permissionData.Setup(x => x.GetUserAsync("dispatcher-1", It.IsAny())) .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" }, })); var user = new ClaimsPrincipal(new ClaimsIdentity( new[] { new Claim(ClaimTypes.NameIdentifier, "dispatcher-1") }, "test")); static async Task<(WorkOrderUpliftDto? Result, Exception? Error)> Capture( Func> 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(Assert.Single(outcomes, outcome => outcome.Error != null).Error); upliftData.Verify(x => x.HasActiveAsync(10, It.IsAny()), Times.Exactly(2)); } }