mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
Merge remote-tracking branch 'origin/main' into lane/sh-327
# Conflicts: # Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs
This commit is contained in:
commit
beb091fcc8
13 changed files with 441 additions and 14 deletions
|
|
@ -139,7 +139,8 @@ public sealed class UpliftWorkflowTests
|
||||||
private static VendorPortalService NewPortalService(
|
private static VendorPortalService NewPortalService(
|
||||||
ApplicationDbContext context,
|
ApplicationDbContext context,
|
||||||
IEmailSender emailSender,
|
IEmailSender emailSender,
|
||||||
IVendorDocumentStoragePort? storage = null)
|
IVendorDocumentStoragePort? storage = null,
|
||||||
|
IUpliftDataService? upliftData = null)
|
||||||
{
|
{
|
||||||
var vendorData = new VendorDataService(context);
|
var vendorData = new VendorDataService(context);
|
||||||
var tokenService = new VendorPortalTokenService(vendorData, Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions()));
|
var tokenService = new VendorPortalTokenService(vendorData, Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions()));
|
||||||
|
|
@ -150,7 +151,7 @@ public sealed class UpliftWorkflowTests
|
||||||
return new VendorPortalService(
|
return new VendorPortalService(
|
||||||
tokenService,
|
tokenService,
|
||||||
new DispatchDataService(context),
|
new DispatchDataService(context),
|
||||||
new UpliftDataService(context),
|
upliftData ?? new UpliftDataService(context),
|
||||||
commentData.Object,
|
commentData.Object,
|
||||||
Mock.Of<IUserDataService>(),
|
Mock.Of<IUserDataService>(),
|
||||||
emailSender,
|
emailSender,
|
||||||
|
|
@ -214,6 +215,36 @@ public sealed class UpliftWorkflowTests
|
||||||
result.RequiredTier.Should().Be(2);
|
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<IUpliftDataService>();
|
||||||
|
upliftData.Setup(x => x.HasActiveAsync(dispatch.Id, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(false);
|
||||||
|
upliftData.Setup(x => x.StageAsync(It.IsAny<DispatchUpliftRequest>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
upliftData.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.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<SeaHaven.Services.Exceptions.UpliftConflictException>();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Evidence scan + cross-vendor/dispatch scoping ---
|
// --- Evidence scan + cross-vendor/dispatch scoping ---
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Moq;
|
using Moq;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
|
|
@ -309,6 +310,34 @@ public class VendorPortalControllerTests
|
||||||
result.Should().BeOfType<BadRequestObjectResult>();
|
result.Should().BeOfType<BadRequestObjectResult>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestUplift_ConcurrentActiveRequest_ReturnsConflict()
|
||||||
|
{
|
||||||
|
var service = new Mock<IVendorPortalService>();
|
||||||
|
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Session);
|
||||||
|
service.Setup(x => x.RequestUpliftAsync(
|
||||||
|
Session,
|
||||||
|
10,
|
||||||
|
1500m,
|
||||||
|
"Need more parts",
|
||||||
|
It.IsAny<string?>(),
|
||||||
|
It.IsAny<int?>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.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<ConflictObjectResult>().Subject;
|
||||||
|
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
|
||||||
|
conflict.Value.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RequestUplift_Success_ReturnsResult()
|
public async Task RequestUplift_Success_ReturnsResult()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Moq;
|
using Moq;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
@ -125,6 +126,28 @@ public sealed class WorkOrderUpliftControllerTests
|
||||||
response.Message.Should().Contain("reference");
|
response.Message.Should().Contain("reference");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateUplift_ConcurrentActiveRequest_ReturnsConflict()
|
||||||
|
{
|
||||||
|
var service = new Mock<IWorkOrderUpliftService>();
|
||||||
|
service.Setup(x => x.CreateAsync(
|
||||||
|
7,
|
||||||
|
It.IsAny<CreateWorkOrderUpliftRequestDto>(),
|
||||||
|
It.IsAny<ClaimsPrincipal>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.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<ConflictObjectResult>().Subject;
|
||||||
|
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
|
||||||
|
conflict.Value.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CancelUplift_NotFound_Returns404()
|
public async Task CancelUplift_NotFound_Returns404()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
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" },
|
||||||
|
}));
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Controllers
|
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);
|
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 });
|
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||||
}
|
}
|
||||||
|
catch (UpliftConflictException ex)
|
||||||
|
{
|
||||||
|
return Conflict(new Response { Status = "Error", Message = ex.Message });
|
||||||
|
}
|
||||||
catch (KeyNotFoundException)
|
catch (KeyNotFoundException)
|
||||||
{
|
{
|
||||||
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
||||||
return Ok(new DataResponse { Status = "Success", Data = created });
|
return Ok(new DataResponse { Status = "Success", Data = created });
|
||||||
}
|
}
|
||||||
|
catch (UpliftConflictException ex)
|
||||||
|
{
|
||||||
|
return Conflict(new Response { Status = "Error", Message = ex.Message });
|
||||||
|
}
|
||||||
catch (KeyNotFoundException ex)
|
catch (KeyNotFoundException ex)
|
||||||
{
|
{
|
||||||
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Resource not found") });
|
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Resource not found") });
|
||||||
|
|
|
||||||
|
|
@ -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.")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ using System.Collections.Concurrent;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Exceptions;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
namespace SeaHaven.DataServices.Implementation
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
|
@ -544,7 +545,58 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
|
|
||||||
public async Task SaveChangesAsync(CancellationToken cancellationToken)
|
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
|
||||||
|
&& IsActiveDispatchIndexViolationMessage(databaseException.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsActiveDispatchIndexViolationMessage(string message)
|
||||||
|
{
|
||||||
|
const string activeDispatchIndex = "'IX_DispatchUpliftRequests_DispatchId'";
|
||||||
|
return message.Contains(activeDispatchIndex, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<T> ExecuteWorkOrderMutationAsync<T>(
|
public async Task<T> ExecuteWorkOrderMutationAsync<T>(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
[assembly: InternalsVisibleTo("Api.SeaHavenIndustries.Tests")]
|
[assembly: InternalsVisibleTo("Api.SeaHavenIndustries.Tests")]
|
||||||
|
[assembly: InternalsVisibleTo("SeaHavenIndustries.Tests")]
|
||||||
|
|
|
||||||
9
SeaHaven.Services/Exceptions/UpliftConflictException.cs
Normal file
9
SeaHaven.Services/Exceptions/UpliftConflictException.cs
Normal file
|
|
@ -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.")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ using Microsoft.Extensions.Options;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.Configuration;
|
using SeaHaven.Services.Configuration;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
namespace SeaHaven.Services.Implementation
|
namespace SeaHaven.Services.Implementation
|
||||||
|
|
@ -651,7 +652,14 @@ namespace SeaHaven.Services.Implementation
|
||||||
CreatedAt = now
|
CreatedAt = now
|
||||||
}, cancellationToken);
|
}, 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
|
// Notification is best-effort and persisted separately from workflow state: a failure
|
||||||
// never destroys the actionable request (the lifecycle sweep retries by sentinel).
|
// never destroys the actionable request (the lifecycle sweep retries by sentinel).
|
||||||
|
|
|
||||||
|
|
@ -78,17 +78,24 @@ namespace SeaHaven.Services.Implementation
|
||||||
var notes = request.Notes?.Trim() ?? "";
|
var notes = request.Notes?.Trim() ?? "";
|
||||||
var accountFilter = _accountResolver.ResolveAccountFilter(user);
|
var accountFilter = _accountResolver.ResolveAccountFilter(user);
|
||||||
|
|
||||||
return await _upliftData.ExecuteWorkOrderMutationAsync(
|
try
|
||||||
workOrderId,
|
{
|
||||||
ct => CreateLockedAsync(
|
return await _upliftData.ExecuteWorkOrderMutationAsync(
|
||||||
workOrderId,
|
workOrderId,
|
||||||
request.Amount,
|
ct => CreateLockedAsync(
|
||||||
notes,
|
workOrderId,
|
||||||
userId,
|
request.Amount,
|
||||||
requesterName,
|
notes,
|
||||||
accountFilter,
|
userId,
|
||||||
ct),
|
requesterName,
|
||||||
cancellationToken);
|
accountFilter,
|
||||||
|
ct),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException)
|
||||||
|
{
|
||||||
|
throw new UpliftConflictException();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task EnsureCanRequestUpliftAsync(
|
private async Task EnsureCanRequestUpliftAsync(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,117 @@ namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
public sealed class UpliftDataServiceTransactionTests
|
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()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("DataSource=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.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<SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException>(
|
||||||
|
() => 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<ApplicationDbContext>()
|
||||||
|
.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<DbUpdateException>(
|
||||||
|
() => data.SaveChangesAsync(CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ExecuteWorkOrderMutationAsync_ReleasesGate_WhenTransactionInitializationFails()
|
public async Task ExecuteWorkOrderMutationAsync_ReleasesGate_WhenTransactionInitializationFails()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue