From 8af9ad076fdf5a063b1a547a78bd1c7076f2085c Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 01:24:43 -0300 Subject: [PATCH 1/3] fix: map concurrent uplift inserts to conflict --- .../UpliftWorkflowTests.cs | 35 +++++- .../VendorPortalControllerTests.cs | 29 +++++ .../WorkOrderUpliftControllerTests.cs | 23 ++++ .../WorkOrderUpliftServiceConflictTests.cs | 113 ++++++++++++++++++ .../Controllers/VendorPortalController.cs | 5 + .../Controllers/WorkOrderDetailController.cs | 4 + .../UpliftDispatchConflictException.cs | 9 ++ .../Implementation/UpliftDataService.cs | 50 +++++++- .../Exceptions/UpliftConflictException.cs | 9 ++ .../Implementation/VendorPortalService.cs | 10 +- .../Implementation/WorkOrderUpliftService.cs | 27 +++-- .../UpliftDataServiceTransactionTests.cs | 101 ++++++++++++++++ 12 files changed, 401 insertions(+), 14 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs create mode 100644 SeaHaven.DataServices/Exceptions/UpliftDispatchConflictException.cs create mode 100644 SeaHaven.Services/Exceptions/UpliftConflictException.cs diff --git a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs index 9b3f220..b57d36f 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs @@ -139,7 +139,8 @@ public sealed class UpliftWorkflowTests private static VendorPortalService NewPortalService( ApplicationDbContext context, IEmailSender emailSender, - IVendorDocumentStoragePort? storage = null) + IVendorDocumentStoragePort? storage = null, + IUpliftDataService? upliftData = null) { var vendorData = new VendorDataService(context); var tokenService = new VendorPortalTokenService(vendorData, Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions())); @@ -150,7 +151,7 @@ public sealed class UpliftWorkflowTests return new VendorPortalService( tokenService, new DispatchDataService(context), - new UpliftDataService(context), + upliftData ?? new UpliftDataService(context), commentData.Object, Mock.Of(), emailSender, @@ -214,6 +215,36 @@ public sealed class UpliftWorkflowTests result.RequiredTier.Should().Be(2); } + [Fact] + public async Task RequestUplift_ConcurrentActiveRequest_MapsPersistenceConflict() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); + context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); + await context.SaveChangesAsync(); + + var upliftData = new Mock(); + upliftData.Setup(x => x.HasActiveAsync(dispatch.Id, It.IsAny())) + .ReturnsAsync(false); + upliftData.Setup(x => x.StageAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + upliftData.Setup(x => x.SaveChangesAsync(It.IsAny())) + .ThrowsAsync(new SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException()); + + var service = NewPortalService(context, new FakeEmailSender(deliver: true), upliftData: upliftData.Object); + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var act = () => service.RequestUpliftAsync( + session!, + dispatch.Id, + 1500m, + "reason", + null, + 1, + CancellationToken.None); + + await act.Should().ThrowAsync(); + } + // --- Evidence scan + cross-vendor/dispatch scoping --- [Fact] diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs index dd9e40e..bc097b5 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Moq; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Interfaces; using Xunit; @@ -309,6 +310,34 @@ public class VendorPortalControllerTests result.Should().BeOfType(); } + [Fact] + public async Task RequestUplift_ConcurrentActiveRequest_ReturnsConflict() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.RequestUpliftAsync( + Session, + 10, + 1500m, + "Need more parts", + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new UpliftConflictException()); + var controller = NewController(service); + + var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody + { + RequestedNTE = 1500m, + Reason = "Need more parts", + }); + + var conflict = result.Should().BeOfType().Subject; + conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); + conflict.Value.Should().NotBeNull(); + } + [Fact] public async Task RequestUplift_Success_ReturnsResult() { diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs index b3cdbfc..3784558 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Moq; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Interfaces; using System.Security.Claims; using Xunit; @@ -78,6 +79,28 @@ public sealed class WorkOrderUpliftControllerTests Assert.Equal(12, created.Id); } + [Fact] + public async Task CreateUplift_ConcurrentActiveRequest_ReturnsConflict() + { + var service = new Mock(); + service.Setup(x => x.CreateAsync( + 7, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new UpliftConflictException()); + + var controller = NewController(service); + var result = await controller.CreateUplift( + 7, + new CreateWorkOrderUpliftRequestDto { Amount = 750m, Notes = "Extra labor" }, + CancellationToken.None); + + var conflict = result.Should().BeOfType().Subject; + conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); + conflict.Value.Should().NotBeNull(); + } + [Fact] public async Task CancelUplift_NotFound_Returns404() { diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs new file mode 100644 index 0000000..129e360 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs @@ -0,0 +1,113 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Moq; +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() + { + var upliftData = new Mock(); + 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(new Dispatch { Id = 10, Status = "Scheduled", NTEAmount = 1000m }); + 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()); + + var service = new WorkOrderUpliftService( + upliftData.Object, + dispatchData.Object, + detailData.Object, + accountResolver.Object, + userData.Object, + 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)); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index 39dd90b..636f963 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers @@ -217,6 +218,10 @@ namespace Api.SeaHavenIndustries.Controllers var result = await _portalService.RequestUpliftAsync(session, id, body?.RequestedNTE ?? 0m, body?.Reason, body?.RequestKey, body?.EvidenceDocumentId, cancellationToken); return Ok(new DataResponse { Status = "Success", Data = result }); } + catch (UpliftConflictException ex) + { + return Conflict(new Response { Status = "Error", Message = ex.Message }); + } catch (KeyNotFoundException) { return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs index d975b32..742eaaf 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs @@ -164,6 +164,10 @@ namespace Api.SeaHavenIndustries.Controllers return NotFound(new Response { Status = "Error", Message = "Work order not found." }); return Ok(new DataResponse { Status = "Success", Data = created }); } + catch (UpliftConflictException ex) + { + return Conflict(new Response { Status = "Error", Message = ex.Message }); + } catch (KeyNotFoundException ex) { return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Resource not found") }); diff --git a/SeaHaven.DataServices/Exceptions/UpliftDispatchConflictException.cs b/SeaHaven.DataServices/Exceptions/UpliftDispatchConflictException.cs new file mode 100644 index 0000000..0063ebd --- /dev/null +++ b/SeaHaven.DataServices/Exceptions/UpliftDispatchConflictException.cs @@ -0,0 +1,9 @@ +namespace SeaHaven.DataServices.Exceptions; + +public sealed class UpliftDispatchConflictException : Exception +{ + public UpliftDispatchConflictException() + : base("An active uplift request already exists for this dispatch.") + { + } +} diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 1d72cf5..a41d487 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Exceptions; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation @@ -525,7 +526,54 @@ namespace SeaHaven.DataServices.Implementation public async Task SaveChangesAsync(CancellationToken cancellationToken) { - await _context.SaveChangesAsync(cancellationToken); + try + { + await _context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException exception) when (IsActiveDispatchUniqueIndexViolation(exception)) + { + throw new UpliftDispatchConflictException(); + } + } + + private bool IsActiveDispatchUniqueIndexViolation(DbUpdateException exception) + { + var provider = _context.Database.ProviderName; + var databaseException = exception.GetBaseException(); + var exceptionType = databaseException.GetType(); + + if (provider?.Contains("SqlServer", StringComparison.OrdinalIgnoreCase) == true + && exceptionType.FullName == "Microsoft.Data.SqlClient.SqlException") + { + var number = exceptionType.GetProperty("Number")?.GetValue(databaseException) as int?; + return number is 2601 or 2627 + && databaseException.Message.Contains( + "IX_DispatchUpliftRequests_DispatchId", + StringComparison.OrdinalIgnoreCase); + } + + if (provider?.Contains("Sqlite", StringComparison.OrdinalIgnoreCase) == true + && exceptionType.FullName == "Microsoft.Data.Sqlite.SqliteException") + { + var extendedCode = exceptionType.GetProperty("SqliteExtendedErrorCode")?.GetValue(databaseException) as int?; + const string uniqueConstraintPrefix = "UNIQUE constraint failed: "; + var message = databaseException.Message; + var prefixIndex = message.IndexOf(uniqueConstraintPrefix, StringComparison.OrdinalIgnoreCase); + if (extendedCode != 2067 || prefixIndex < 0) + return false; + + var columnsStart = prefixIndex + uniqueConstraintPrefix.Length; + var columnsEnd = message.IndexOf('\'', columnsStart); + if (columnsEnd < 0) + columnsEnd = message.Length; + var columns = message[columnsStart..columnsEnd].Trim(); + return string.Equals( + columns, + "DispatchUpliftRequests.DispatchId", + StringComparison.OrdinalIgnoreCase); + } + + return false; } public async Task ExecuteWorkOrderMutationAsync( diff --git a/SeaHaven.Services/Exceptions/UpliftConflictException.cs b/SeaHaven.Services/Exceptions/UpliftConflictException.cs new file mode 100644 index 0000000..9e21b4e --- /dev/null +++ b/SeaHaven.Services/Exceptions/UpliftConflictException.cs @@ -0,0 +1,9 @@ +namespace SeaHaven.Services.Exceptions; + +public sealed class UpliftConflictException : Exception +{ + public UpliftConflictException() + : base("An active uplift request already exists for the associated dispatch. Refresh and retry.") + { + } +} diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 5fc5140..c595809 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation @@ -651,7 +652,14 @@ namespace SeaHaven.Services.Implementation CreatedAt = now }, cancellationToken); - await _upliftData.SaveChangesAsync(cancellationToken); + try + { + await _upliftData.SaveChangesAsync(cancellationToken); + } + catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException) + { + throw new UpliftConflictException(); + } // Notification is best-effort and persisted separately from workflow state: a failure // never destroys the actionable request (the lifecycle sweep retries by sentinel). diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 149aeaa..cb05911 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -69,17 +69,24 @@ namespace SeaHaven.Services.Implementation var notes = request.Notes?.Trim() ?? ""; var accountFilter = _accountResolver.ResolveAccountFilter(user); - return await _upliftData.ExecuteWorkOrderMutationAsync( - workOrderId, - ct => CreateLockedAsync( + try + { + return await _upliftData.ExecuteWorkOrderMutationAsync( workOrderId, - request.Amount, - notes, - userId, - requesterName, - accountFilter, - ct), - cancellationToken); + ct => CreateLockedAsync( + workOrderId, + request.Amount, + notes, + userId, + requesterName, + accountFilter, + ct), + cancellationToken); + } + catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException) + { + throw new UpliftConflictException(); + } } private async Task CreateLockedAsync( diff --git a/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs b/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs index f585766..1742f32 100644 --- a/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs +++ b/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs @@ -9,6 +9,107 @@ namespace SeaHavenIndustries.Tests; public sealed class UpliftDataServiceTransactionTests { + [Fact] + public async Task SaveChangesAsync_ActiveUpliftOnSameDispatch_MapsConcurrentInsertConflict() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new ApplicationDbContext(options); + await context.Database.EnsureCreatedAsync(); + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + context.Dispatches.Add(new Dispatch + { + Id = 10, + VendorId = 1, + DispatchNumber = "DIS-10", + Status = "Scheduled", + }); + await context.SaveChangesAsync(); + + var data = new UpliftDataService(context); + await data.StageAsync(new DispatchUpliftRequest + { + DispatchId = 10, + RequestedNTE = 1500m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending", + }, CancellationToken.None); + await data.SaveChangesAsync(CancellationToken.None); + + // The database index is the final guard when competing requests pass + // their dispatch-scoped active-request checks. + var exception = await Assert.ThrowsAsync( + () => data.ExecuteWorkOrderMutationAsync( + 2, + async cancellationToken => + { + await data.StageAsync(new DispatchUpliftRequest + { + DispatchId = 10, + RequestedNTE = 1700m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending", + }, cancellationToken); + await data.SaveChangesAsync(cancellationToken); + return true; + }, + CancellationToken.None)); + Assert.Equal("An active uplift request already exists for this dispatch.", exception.Message); + Assert.Equal(1, await context.DispatchUpliftRequests.CountAsync()); + } + + [Fact] + public async Task SaveChangesAsync_RequestKeyConflict_IsNotMappedToActiveDispatchConflict() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new ApplicationDbContext(options); + await context.Database.EnsureCreatedAsync(); + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + context.Dispatches.Add(new Dispatch + { + Id = 10, + VendorId = 1, + DispatchNumber = "DIS-10", + Status = "Scheduled", + }); + await context.SaveChangesAsync(); + + var data = new UpliftDataService(context); + await data.StageAsync(new DispatchUpliftRequest + { + DispatchId = 10, + RequestKey = "same-key", + RequestedNTE = 1500m, + Status = "Approved", + RequiredTier = 1, + NotificationStatus = "Sent", + }, CancellationToken.None); + await data.SaveChangesAsync(CancellationToken.None); + await data.StageAsync(new DispatchUpliftRequest + { + DispatchId = 10, + RequestKey = "same-key", + RequestedNTE = 1700m, + Status = "Approved", + RequiredTier = 1, + NotificationStatus = "Sent", + }, CancellationToken.None); + + await Assert.ThrowsAsync( + () => data.SaveChangesAsync(CancellationToken.None)); + } + [Fact] public async Task ExecuteWorkOrderMutationAsync_ReleasesGate_WhenTransactionInitializationFails() { From 0ca9b767edafe5eb6d92452e5a14d0de4dc89fa7 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 01:39:16 -0300 Subject: [PATCH 2/3] fix: distinguish uplift request key conflicts --- .../Implementation/UpliftDataService.cs | 10 +++++++--- SeaHaven.DataServices/Properties/AssemblyInfo.cs | 1 + .../UpliftDataServiceTransactionTests.cs | 10 ++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index a41d487..64df777 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -547,9 +547,7 @@ namespace SeaHaven.DataServices.Implementation { var number = exceptionType.GetProperty("Number")?.GetValue(databaseException) as int?; return number is 2601 or 2627 - && databaseException.Message.Contains( - "IX_DispatchUpliftRequests_DispatchId", - StringComparison.OrdinalIgnoreCase); + && IsActiveDispatchIndexViolationMessage(databaseException.Message); } if (provider?.Contains("Sqlite", StringComparison.OrdinalIgnoreCase) == true @@ -576,6 +574,12 @@ namespace SeaHaven.DataServices.Implementation return false; } + internal static bool IsActiveDispatchIndexViolationMessage(string message) + { + const string activeDispatchIndex = "'IX_DispatchUpliftRequests_DispatchId'"; + return message.Contains(activeDispatchIndex, StringComparison.OrdinalIgnoreCase); + } + public async Task ExecuteWorkOrderMutationAsync( int workOrderId, Func> work, diff --git a/SeaHaven.DataServices/Properties/AssemblyInfo.cs b/SeaHaven.DataServices/Properties/AssemblyInfo.cs index 75fc406..15ea248 100644 --- a/SeaHaven.DataServices/Properties/AssemblyInfo.cs +++ b/SeaHaven.DataServices/Properties/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Api.SeaHavenIndustries.Tests")] +[assembly: InternalsVisibleTo("SeaHavenIndustries.Tests")] diff --git a/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs b/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs index 1742f32..2825fcd 100644 --- a/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs +++ b/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs @@ -9,6 +9,16 @@ namespace SeaHavenIndustries.Tests; public sealed class UpliftDataServiceTransactionTests { + [Theory] + [InlineData("Cannot insert duplicate key row with unique index 'IX_DispatchUpliftRequests_DispatchId'.", true)] + [InlineData("Cannot insert duplicate key row with unique index 'IX_DispatchUpliftRequests_DispatchId_RequestKey'.", false)] + public void IsActiveDispatchIndexViolationMessage_MatchesOnlyTheActiveDispatchIndex( + string message, + bool expected) + { + Assert.Equal(expected, UpliftDataService.IsActiveDispatchIndexViolationMessage(message)); + } + [Fact] public async Task SaveChangesAsync_ActiveUpliftOnSameDispatch_MapsConcurrentInsertConflict() { From 2be74b261f8ea56e0d7ec9cb03450f7b6cd623d5 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 22:28:04 -0300 Subject: [PATCH 3/3] test(uplifts): share conflict fixture dispatch through owned and linked work orders After SH-393 the uplift dispatch resolves only through dispatches the work order owns or links through DispatchWorkOrders. Model the shared dispatch that way so the concurrent-insert conflict test exercises the conflict mapping again; assertions are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../WorkOrderUpliftServiceConflictTests.cs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs index 129e360..5366d8c 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftServiceConflictTests.cs @@ -18,7 +18,25 @@ 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; @@ -62,7 +80,7 @@ public sealed class WorkOrderUpliftServiceConflictTests var dispatchData = new Mock(); dispatchData.Setup(x => x.GetByIdAsync(10)) - .ReturnsAsync(new Dispatch { Id = 10, Status = "Scheduled", NTEAmount = 1000m }); + .ReturnsAsync(sharedDispatch); dispatchData.Setup(x => x.StageAuditLogAsync(It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask);