diff --git a/SeaHaven.DataServices/Implementation/DispatchDataService.cs b/SeaHaven.DataServices/Implementation/DispatchDataService.cs index 16d5747..6511ad6 100644 --- a/SeaHaven.DataServices/Implementation/DispatchDataService.cs +++ b/SeaHaven.DataServices/Implementation/DispatchDataService.cs @@ -122,25 +122,27 @@ namespace SeaHaven.DataServices.Implementation return await _context.Dispatches.CountAsync(); } - public async Task> GetByWorkOrderIdAsync(int workOrderId) + public async Task> GetByWorkOrderIdAsync( + int workOrderId, + CancellationToken cancellationToken = default) { var dispatchIds = await _context.DispatchWorkOrders .Where(dwo => dwo.WorkOrderId == workOrderId) .Select(dwo => dwo.DispatchId) .Distinct() - .ToListAsync(); + .ToListAsync(cancellationToken); var directIds = await _context.Dispatches .Where(d => d.WorkOrderId == workOrderId) .Select(d => d.Id) - .ToListAsync(); + .ToListAsync(cancellationToken); var allIds = dispatchIds.Union(directIds).Distinct().ToList(); return await _context.Dispatches .Where(d => allIds.Contains(d.Id)) .Include(d => d.Vendor) - .ToListAsync(); + .ToListAsync(cancellationToken); } public async Task> GetByWorkOrderIdsAsync(IEnumerable workOrderIds) diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 7db6a01..1d72cf5 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -535,22 +535,25 @@ namespace SeaHaven.DataServices.Implementation { var gate = WorkOrderGates.GetOrAdd(workOrderId, _ => new SemaphoreSlim(1, 1)); await gate.WaitAsync(cancellationToken); - await using var transaction = _context.Database.IsRelational() - ? await _context.Database.BeginTransactionAsync(cancellationToken) - : null; try { - await LockWorkOrderRowAsync(workOrderId, cancellationToken); - var result = await work(cancellationToken); - if (transaction is not null) - await transaction.CommitAsync(cancellationToken); - return result; - } - catch - { - if (transaction is not null) - await transaction.RollbackAsync(cancellationToken); - throw; + await using var transaction = _context.Database.IsRelational() + ? await _context.Database.BeginTransactionAsync(cancellationToken) + : null; + try + { + await LockWorkOrderRowAsync(workOrderId, cancellationToken); + var result = await work(cancellationToken); + if (transaction is not null) + await transaction.CommitAsync(cancellationToken); + return result; + } + catch + { + if (transaction is not null) + await transaction.RollbackAsync(cancellationToken); + throw; + } } finally { diff --git a/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs b/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs index efe7dcc..56f3581 100644 --- a/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs @@ -11,7 +11,9 @@ namespace SeaHaven.DataServices.Interfaces Task GetByIdWithVendorAndWorkOrderAsync(int id); Task> GetAllAsync(); Task> GetByVendorIdAsync(int vendorId); - Task> GetByWorkOrderIdAsync(int workOrderId); + Task> GetByWorkOrderIdAsync( + int workOrderId, + CancellationToken cancellationToken = default); Task> GetByWorkOrderIdsAsync(IEnumerable workOrderIds); Task> GetByStatusAsync(string status); Task<(IEnumerable Items, int TotalCount)> GetPagedAsync(int page, int pageSize, string? search = null, string? status = null); diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 6a2c388..149aeaa 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -95,7 +95,7 @@ namespace SeaHaven.Services.Implementation workOrderId, cancellationToken, accountFilter); - if (workOrder?.PrimaryDispatchId is not int dispatchId) + if (workOrder == null) throw new InvalidOperationException("Work order has no primary dispatch for uplift requests"); if (workOrder.LifecycleStatus is LifecycleStatus.Completed or LifecycleStatus.Canceled) @@ -104,14 +104,30 @@ namespace SeaHaven.Services.Implementation $"Cannot create an uplift on a '{workOrder.LifecycleStatus}' work order"); } - var dispatch = await _dispatchData.GetByIdAsync(dispatchId); - if (dispatch == null) - throw new KeyNotFoundException("Dispatch not found"); + Dispatch? dispatch; + if (workOrder.PrimaryDispatchId is int primaryDispatchId) + { + dispatch = await _dispatchData.GetByIdAsync(primaryDispatchId); + if (dispatch == null) + throw new KeyNotFoundException("Dispatch not found"); + } + else + { + dispatch = (await _dispatchData.GetByWorkOrderIdAsync(workOrderId, cancellationToken)) + .Where(candidate => candidate.IsDeleted != true) + .OrderByDescending(candidate => candidate.DispatchedAt ?? candidate.CreatedDate) + .ThenByDescending(candidate => candidate.Id) + .FirstOrDefault(); + + if (dispatch == null) + throw new InvalidOperationException("Work order has no primary dispatch for uplift requests"); + } if (IsTerminalForUplift(dispatch.Status)) throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch"); - if (await _upliftData.HasPendingForWorkOrderAsync(workOrderId, cancellationToken)) + if (await _upliftData.HasPendingForWorkOrderAsync(workOrderId, cancellationToken) + || await _upliftData.HasActiveAsync(dispatch.Id, cancellationToken)) throw new InvalidOperationException("An open uplift request already exists for this work order"); var now = _timeProvider.GetUtcNow().UtcDateTime; @@ -421,7 +437,7 @@ namespace SeaHaven.Services.Implementation { await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { - WorkOrderId = dispatch.WorkOrderId ?? workOrderId, + WorkOrderId = workOrderId, UserId = userId, FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", OldValue = isStatusTransition ? oldValue.ToString() : $"${Convert.ToDecimal(oldValue):F2}", diff --git a/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs b/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs new file mode 100644 index 0000000..f585766 --- /dev/null +++ b/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs @@ -0,0 +1,77 @@ +using System.Data.Common; +using Data.SeaHavenIndustries; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using SeaHaven.DataServices.Implementation; + +namespace SeaHavenIndustries.Tests; + +public sealed class UpliftDataServiceTransactionTests +{ + [Fact] + public async Task ExecuteWorkOrderMutationAsync_ReleasesGate_WhenTransactionInitializationFails() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + + var failingOptions = new DbContextOptionsBuilder() + .UseSqlite(connection) + .AddInterceptors(new ThrowOnceTransactionInterceptor()) + .Options; + + await using (var failedContext = new ApplicationDbContext(failingOptions)) + { + var failedService = new UpliftDataService(failedContext); + + await Assert.ThrowsAsync(() => + failedService.ExecuteWorkOrderMutationAsync( + 387, + _ => Task.FromResult(true), + CancellationToken.None)); + } + + var retryOptions = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var retryContext = new ApplicationDbContext(retryOptions); + var retryService = new UpliftDataService(retryContext); + + var retry = retryService.ExecuteWorkOrderMutationAsync( + 387, + _ => Task.FromResult("entered"), + CancellationToken.None); + + Assert.Equal("entered", await retry.WaitAsync(TimeSpan.FromSeconds(1))); + } + + private sealed class ThrowOnceTransactionInterceptor : DbTransactionInterceptor + { + private int _remaining = 1; + + public override InterceptionResult TransactionStarting( + DbConnection connection, + TransactionStartingEventData eventData, + InterceptionResult result) + { + ThrowOnce(); + return base.TransactionStarting(connection, eventData, result); + } + + public override ValueTask> TransactionStartingAsync( + DbConnection connection, + TransactionStartingEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + ThrowOnce(); + return base.TransactionStartingAsync(connection, eventData, result, cancellationToken); + } + + private void ThrowOnce() + { + if (Interlocked.Exchange(ref _remaining, 0) == 1) + throw new InvalidOperationException("Forced transaction initialization failure"); + } + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs index 7ec8856..13e5c22 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -46,7 +46,8 @@ public sealed class WorkOrderUpliftServiceTests private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync( ApplicationDbContext context, - WorkOrderType type = WorkOrderType.PM) + WorkOrderType type = WorkOrderType.PM, + int? primaryDispatchId = 10) { await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); context.Users.Add(new ApplicationUser @@ -70,7 +71,7 @@ public sealed class WorkOrderUpliftServiceTests { Id = 1, InternalWONumber = "10000000001", - PrimaryDispatchId = 10, + PrimaryDispatchId = primaryDispatchId, AccountId = 1, WorkOrderType = type, }; @@ -123,6 +124,56 @@ public sealed class WorkOrderUpliftServiceTests Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); } + [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_PmAmountAboveCap_CreatesPendingRequest() { @@ -233,6 +284,35 @@ public sealed class WorkOrderUpliftServiceTests 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); + dispatch.WorkOrderId = null; + 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(() => + 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() {