mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # Api.SeaHavenIndustries/Controllers/VendorPortalController.cs # SeaHaven.Services/Implementation/VendorPortalService.cs
This commit is contained in:
commit
f3ef11b504
26 changed files with 5497 additions and 67 deletions
|
|
@ -319,7 +319,7 @@ public sealed class TeamMemberServiceTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Create_ConcurrentDuplicateEmail_ReturnsAlreadyInUseInsteadOf500()
|
public async Task Create_ConcurrentDuplicateEmail_ReturnsAlreadyInUseInsteadOf500()
|
||||||
{
|
{
|
||||||
var service = NewService(out var userManager, out var roleManager, out _, out _, out _, out _);
|
var service = NewService(out var userManager, out var roleManager, out _, out _, out var userData, out _);
|
||||||
userManager
|
userManager
|
||||||
.Setup(manager => manager.FindByEmailAsync(It.IsAny<string>()))
|
.Setup(manager => manager.FindByEmailAsync(It.IsAny<string>()))
|
||||||
.ReturnsAsync((ApplicationUser?)null);
|
.ReturnsAsync((ApplicationUser?)null);
|
||||||
|
|
@ -332,10 +332,57 @@ public sealed class TeamMemberServiceTests
|
||||||
|
|
||||||
result.Success.Should().BeFalse();
|
result.Success.Should().BeFalse();
|
||||||
result.Error.Should().Be("Email is already in use.");
|
result.Error.Should().Be("Email is already in use.");
|
||||||
|
userData.Verify(
|
||||||
|
data => data.ExecuteTransactionalAsync(It.IsAny<Func<CancellationToken, Task>>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Once);
|
||||||
userManager.Verify(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>()), Times.Never);
|
userManager.Verify(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>()), Times.Never);
|
||||||
userManager.Verify(manager => manager.DeleteAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
userManager.Verify(manager => manager.DeleteAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_MidSequencePersistenceFailure_PropagatesWithoutCompensation()
|
||||||
|
{
|
||||||
|
var service = NewService(out var userManager, out var roleManager, out var areas, out var overrides, out _, out _);
|
||||||
|
ApplicationUser? created = null;
|
||||||
|
userManager
|
||||||
|
.Setup(manager => manager.FindByEmailAsync(It.IsAny<string>()))
|
||||||
|
.ReturnsAsync((ApplicationUser?)null);
|
||||||
|
userManager
|
||||||
|
.Setup(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()))
|
||||||
|
.Callback<ApplicationUser>(user =>
|
||||||
|
{
|
||||||
|
user.Id = "mid-fail-user";
|
||||||
|
created = user;
|
||||||
|
})
|
||||||
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
|
userManager
|
||||||
|
.Setup(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), "Dispatcher"))
|
||||||
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
|
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
|
||||||
|
areas
|
||||||
|
.Setup(data => data.ReplaceAsync(
|
||||||
|
"mid-fail-user",
|
||||||
|
It.Is<IReadOnlyCollection<string>>(value => value.SequenceEqual(new[] { "East", "West" })),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
overrides
|
||||||
|
.Setup(data => data.SetOverridesAsync(
|
||||||
|
"mid-fail-user",
|
||||||
|
It.IsAny<IReadOnlyDictionary<string, UserPermissionState>>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new InvalidOperationException("injected mid-sequence failure"));
|
||||||
|
|
||||||
|
var failure = await Record.ExceptionAsync(() => service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None));
|
||||||
|
|
||||||
|
failure.Should().BeOfType<InvalidOperationException>()
|
||||||
|
.Which.Message.Should().Be("injected mid-sequence failure");
|
||||||
|
created.Should().NotBeNull();
|
||||||
|
areas.Verify(
|
||||||
|
data => data.ReplaceAsync("mid-fail-user", It.IsAny<IReadOnlyCollection<string>>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Once);
|
||||||
|
userManager.Verify(manager => manager.DeleteAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
private static TeamMemberService NewService(
|
private static TeamMemberService NewService(
|
||||||
out Mock<UserManager<ApplicationUser>> userManager,
|
out Mock<UserManager<ApplicationUser>> userManager,
|
||||||
out Mock<RoleManager<IdentityRole>> roleManager,
|
out Mock<RoleManager<IdentityRole>> roleManager,
|
||||||
|
|
@ -350,6 +397,11 @@ public sealed class TeamMemberServiceTests
|
||||||
overrides = new Mock<ITeamPermissionOverrideDataService>();
|
overrides = new Mock<ITeamPermissionOverrideDataService>();
|
||||||
userData = new Mock<IUserDataService>();
|
userData = new Mock<IUserDataService>();
|
||||||
permissions = new Mock<ITeamPermissionService>();
|
permissions = new Mock<ITeamPermissionService>();
|
||||||
|
userData
|
||||||
|
.Setup(data => data.ExecuteTransactionalAsync(
|
||||||
|
It.IsAny<Func<CancellationToken, Task>>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.Returns<Func<CancellationToken, Task>, CancellationToken>((callback, token) => callback(token));
|
||||||
return new TeamMemberService(
|
return new TeamMemberService(
|
||||||
userManager.Object,
|
userManager.Object,
|
||||||
roleManager.Object,
|
roleManager.Object,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -78,6 +79,28 @@ public sealed class WorkOrderUpliftControllerTests
|
||||||
Assert.Equal(12, created.Id);
|
Assert.Equal(12, created.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[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,131 @@
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
// 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>());
|
||||||
|
|
||||||
|
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<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.Helpers;
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
|
@ -218,6 +219,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") });
|
||||||
|
|
|
||||||
4213
Data.SeaHavenIndustries/Migrations/20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder.Designer.cs
generated
Normal file
4213
Data.SeaHavenIndustries/Migrations/20260925001752_SH393_BackfillBoardCreatedDispatchWorkOrder.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,43 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// SH-393: board-created work orders with a vendor stored their primary dispatch with
|
||||||
|
/// no WorkOrderId and no DispatchWorkOrders link, so every owned-or-linked read (uplift
|
||||||
|
/// list, allowance, approval queue WO number, dispatch patches) missed it. Data-only
|
||||||
|
/// heal: assign the dispatch to the single work order that names it as primary. Rows
|
||||||
|
/// already owned or linked, and dispatches named primary by more than one work order,
|
||||||
|
/// are left untouched, so re-running is a no-op.
|
||||||
|
/// </summary>
|
||||||
|
public partial class SH393_BackfillBoardCreatedDispatchWorkOrder : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
UPDATE d
|
||||||
|
SET d.[WorkOrderId] = w.[Id]
|
||||||
|
FROM [Dispatches] AS d
|
||||||
|
INNER JOIN [workOrders] AS w ON w.[PrimaryDispatchId] = d.[Id]
|
||||||
|
WHERE d.[WorkOrderId] IS NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM [DispatchWorkOrders] AS l
|
||||||
|
WHERE l.[DispatchId] = d.[Id])
|
||||||
|
AND (
|
||||||
|
SELECT COUNT(*) FROM [workOrders] AS owner
|
||||||
|
WHERE owner.[PrimaryDispatchId] = d.[Id]) = 1;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// The heal is not reversible: healed rows are indistinguishable from dispatches
|
||||||
|
// that were created with their work order set.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -336,6 +337,25 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.AnyAsync(u => u.Status == "Pending" || u.Status == "ChangesRequested", cancellationToken);
|
.AnyAsync(u => u.Status == "Pending" || u.Status == "ChangesRequested", cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Dispatch?> GetUpliftDispatchForWorkOrderAsync(
|
||||||
|
int workOrderId,
|
||||||
|
int? primaryDispatchId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var candidates = await _context.Dispatches
|
||||||
|
.Where(d =>
|
||||||
|
(d.IsDeleted == null || d.IsDeleted == false)
|
||||||
|
&& (
|
||||||
|
d.WorkOrderId == workOrderId
|
||||||
|
|| d.DispatchWorkOrders!.Any(link => link.WorkOrderId == workOrderId)))
|
||||||
|
.OrderByDescending(d => d.DispatchedAt ?? d.CreatedDate)
|
||||||
|
.ThenByDescending(d => d.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return candidates.FirstOrDefault(d => d.Id == primaryDispatchId)
|
||||||
|
?? candidates.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
|
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return ForWorkOrder(workOrderId)
|
return ForWorkOrder(workOrderId)
|
||||||
|
|
@ -525,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>(
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,25 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
await transaction.CommitAsync(cancellationToken);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task ExecuteTransactionalAsync(
|
||||||
|
Func<CancellationToken, Task> callback,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(callback);
|
||||||
|
|
||||||
|
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await callback(cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(CancellationToken.None);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<string?> GetEmailByIdAsync(
|
public async Task<string?> GetEmailByIdAsync(
|
||||||
string userId, CancellationToken cancellationToken)
|
string userId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken);
|
Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken);
|
||||||
Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken);
|
Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken);
|
||||||
Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||||
|
// SH-393: the tracked dispatch a work-order uplift is written to, resolved through the
|
||||||
|
// same scope the work-order uplift reads use (non-deleted, owned or linked): the
|
||||||
|
// work order's primary dispatch when it qualifies, otherwise its latest qualifying one.
|
||||||
|
Task<Dispatch?> GetUpliftDispatchForWorkOrderAsync(int workOrderId, int? primaryDispatchId, CancellationToken cancellationToken);
|
||||||
Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||||
// Approval queue read contract: set-based per-work-order exposure totals covering
|
// Approval queue read contract: set-based per-work-order exposure totals covering
|
||||||
// auto-approved and admin-approved uplift amounts (pending, rejected,
|
// auto-approved and admin-approved uplift amounts (pending, rejected,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task<ApplicationUser?> GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken);
|
Task<ApplicationUser?> GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken);
|
||||||
Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken);
|
Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken);
|
||||||
Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken);
|
Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken);
|
||||||
|
Task ExecuteTransactionalAsync(Func<CancellationToken, Task> callback, CancellationToken cancellationToken);
|
||||||
Task<string?> GetEmailByIdAsync(string userId, CancellationToken cancellationToken);
|
Task<string?> GetEmailByIdAsync(string userId, CancellationToken cancellationToken);
|
||||||
Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids);
|
Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -52,10 +52,6 @@ public sealed class TeamMemberService : ITeamMemberService
|
||||||
if (await _userManager.FindByEmailAsync(email!) is not null)
|
if (await _userManager.FindByEmailAsync(email!) is not null)
|
||||||
return Failure("Email is already in use.");
|
return Failure("Email is already in use.");
|
||||||
|
|
||||||
var roleError = await EnsureRoleAsync(role!);
|
|
||||||
if (roleError is not null)
|
|
||||||
return Failure(roleError);
|
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var user = new ApplicationUser
|
var user = new ApplicationUser
|
||||||
{
|
{
|
||||||
|
|
@ -72,39 +68,44 @@ public sealed class TeamMemberService : ITeamMemberService
|
||||||
PendingRegistrationCreatedDate = now
|
PendingRegistrationCreatedDate = now
|
||||||
};
|
};
|
||||||
|
|
||||||
IdentityResult createResult;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
createResult = await _userManager.CreateAsync(user);
|
await _userDataService.ExecuteTransactionalAsync(
|
||||||
}
|
async transactionCancellationToken =>
|
||||||
catch (DbUpdateException)
|
{
|
||||||
{
|
var roleError = await EnsureRoleAsync(role!);
|
||||||
// A concurrent create won the race on the unique user-name index
|
if (roleError is not null)
|
||||||
// between the FindByEmailAsync check above and this insert. Surface
|
throw new TeamMemberCreateException(roleError);
|
||||||
// the same conflict message instead of letting the database
|
|
||||||
// exception bubble up as a 500.
|
|
||||||
return Failure("Email is already in use.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!createResult.Succeeded)
|
IdentityResult createResult;
|
||||||
return Failure(createResult.Errors.FirstOrDefault()?.Description ?? "Unable to create team member.");
|
try
|
||||||
|
{
|
||||||
|
createResult = await _userManager.CreateAsync(user);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
// A concurrent create won the race on the unique user-name index
|
||||||
|
// between the FindByEmailAsync check above and this insert. Surface
|
||||||
|
// the same conflict message instead of letting the database
|
||||||
|
// exception bubble up as a 500.
|
||||||
|
throw new TeamMemberCreateException("Email is already in use.");
|
||||||
|
}
|
||||||
|
|
||||||
var addRoleResult = await _userManager.AddToRoleAsync(user, role!);
|
if (!createResult.Succeeded)
|
||||||
if (!addRoleResult.Succeeded)
|
throw new TeamMemberCreateException(createResult.Errors.FirstOrDefault()?.Description ?? "Unable to create team member.");
|
||||||
{
|
|
||||||
await _userManager.DeleteAsync(user);
|
|
||||||
return Failure(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to assign role.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
var addRoleResult = await _userManager.AddToRoleAsync(user, role!);
|
||||||
{
|
if (!addRoleResult.Succeeded)
|
||||||
await _areaDataService.ReplaceAsync(user.Id, areas!, cancellationToken);
|
throw new TeamMemberCreateException(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to assign role.");
|
||||||
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, cancellationToken);
|
|
||||||
|
await _areaDataService.ReplaceAsync(user.Id, areas!, transactionCancellationToken);
|
||||||
|
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, transactionCancellationToken);
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
catch
|
catch (TeamMemberCreateException exception)
|
||||||
{
|
{
|
||||||
await _userManager.DeleteAsync(user);
|
return Failure(exception.Message);
|
||||||
throw;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new CreateTeamMemberOutcomeDTO
|
return new CreateTeamMemberOutcomeDTO
|
||||||
|
|
@ -391,6 +392,13 @@ public sealed class TeamMemberService : ITeamMemberService
|
||||||
private static CreateTeamMemberOutcomeDTO Failure(string error) =>
|
private static CreateTeamMemberOutcomeDTO Failure(string error) =>
|
||||||
new() { Success = false, Error = error };
|
new() { Success = false, Error = error };
|
||||||
|
|
||||||
|
private sealed class TeamMemberCreateException : Exception
|
||||||
|
{
|
||||||
|
public TeamMemberCreateException(string error) : base(error)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static TeamMemberOperationOutcomeDTO OperationFailure(string error) =>
|
private static TeamMemberOperationOutcomeDTO OperationFailure(string error) =>
|
||||||
new() { Success = false, Error = error };
|
new() { Success = false, Error = error };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.Helpers;
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
|
@ -708,7 +709,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).
|
||||||
|
|
|
||||||
|
|
@ -249,10 +249,14 @@ namespace SeaHaven.Services.Implementation
|
||||||
_mutationData.TrackNewWorkOrder(workOrder);
|
_mutationData.TrackNewWorkOrder(workOrder);
|
||||||
var firstOutcome = await _mutationData.SaveAsync(ct);
|
var firstOutcome = await _mutationData.SaveAsync(ct);
|
||||||
|
|
||||||
if (dispatch != null && workOrder.PrimaryDispatchId == null)
|
// SH-393: the first save already fixes up PrimaryDispatchId from the
|
||||||
|
// PrimaryDispatch navigation, so the dispatch's own WorkOrderId must be
|
||||||
|
// backfilled independently. Without it the dispatch belongs to no work order
|
||||||
|
// and every owned-or-linked read (uplifts, dispatch patches) misses it.
|
||||||
|
if (dispatch != null)
|
||||||
{
|
{
|
||||||
workOrder.PrimaryDispatchId = dispatch.Id;
|
workOrder.PrimaryDispatchId ??= dispatch.Id;
|
||||||
dispatch.WorkOrderId = workOrder.Id;
|
dispatch.WorkOrderId ??= workOrder.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,17 +69,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<WorkOrderUpliftDto?> CreateLockedAsync(
|
private async Task<WorkOrderUpliftDto?> CreateLockedAsync(
|
||||||
|
|
@ -104,24 +111,16 @@ namespace SeaHaven.Services.Implementation
|
||||||
$"Cannot create an uplift on a '{workOrder.LifecycleStatus}' work order");
|
$"Cannot create an uplift on a '{workOrder.LifecycleStatus}' work order");
|
||||||
}
|
}
|
||||||
|
|
||||||
Dispatch? dispatch;
|
// SH-393: write to a dispatch the work order's uplift reads resolve back to it.
|
||||||
if (workOrder.PrimaryDispatchId is int primaryDispatchId)
|
// Loading the primary by id alone accepted soft-deleted, unlinked, or other work
|
||||||
{
|
// orders' dispatches, so the request never showed on this work order and never
|
||||||
dispatch = await _dispatchData.GetByIdAsync(primaryDispatchId);
|
// consumed its allowance.
|
||||||
if (dispatch == null)
|
var dispatch = await _upliftData.GetUpliftDispatchForWorkOrderAsync(
|
||||||
throw new KeyNotFoundException("Dispatch not found");
|
workOrderId,
|
||||||
}
|
workOrder.PrimaryDispatchId,
|
||||||
else
|
cancellationToken);
|
||||||
{
|
if (dispatch == null)
|
||||||
dispatch = (await _dispatchData.GetByWorkOrderIdAsync(workOrderId, cancellationToken))
|
throw new InvalidOperationException("Work order has no primary dispatch for uplift requests");
|
||||||
.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))
|
if (IsTerminalForUplift(dispatch.Status))
|
||||||
throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch");
|
throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch");
|
||||||
|
|
|
||||||
230
SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs
Normal file
230
SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using SeaHaven.DataServices.Dto;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.DataServices.DependencyInjection;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.DependencyInjection;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public sealed class TeamMemberCreateTransactionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_MidSequencePersistenceFailure_RollsBackAllMemberRows()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
await using (var setup = new SqliteTeamMemberTestDbContext(options))
|
||||||
|
await setup.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
await using var serviceProvider = BuildServiceProvider(connection, injectFailure: true);
|
||||||
|
ThrowingAfterPersistPermissionDataService failingPermissionData;
|
||||||
|
await using (var createScope = serviceProvider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var service = createScope.ServiceProvider.GetRequiredService<ITeamMemberService>();
|
||||||
|
failingPermissionData = (ThrowingAfterPersistPermissionDataService)createScope.ServiceProvider
|
||||||
|
.GetRequiredService<ITeamPermissionOverrideDataService>();
|
||||||
|
var failure = await Record.ExceptionAsync(() => service.CreateAsync(
|
||||||
|
ValidRequest(),
|
||||||
|
Admin(),
|
||||||
|
CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.IsType<InvalidOperationException>(failure);
|
||||||
|
Assert.Equal("injected mid-sequence failure", failure!.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.NotNull(failingPermissionData.RowsAtFailure);
|
||||||
|
var rowsAtFailure = failingPermissionData.RowsAtFailure!.Value;
|
||||||
|
Assert.Equal(1, rowsAtFailure.Users);
|
||||||
|
Assert.Equal(1, rowsAtFailure.Roles);
|
||||||
|
Assert.Equal(1, rowsAtFailure.UserRoles);
|
||||||
|
Assert.Equal(2, rowsAtFailure.ServiceAreas);
|
||||||
|
Assert.Equal(1, rowsAtFailure.PermissionOverrides);
|
||||||
|
|
||||||
|
await using var verifyScope = serviceProvider.CreateAsyncScope();
|
||||||
|
var verify = verifyScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
Assert.Empty(await verify.Users.AsNoTracking().ToListAsync());
|
||||||
|
Assert.Empty(await verify.Roles.AsNoTracking().ToListAsync());
|
||||||
|
Assert.Empty(await verify.UserRoles.AsNoTracking().ToListAsync());
|
||||||
|
Assert.Empty(await verify.UserServiceAreas.AsNoTracking().ToListAsync());
|
||||||
|
Assert.Empty(await verify.UserPermissionOverrides.AsNoTracking().ToListAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_SuccessfulTransaction_CommitsPendingMemberAndAssociations()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
await using (var setup = new SqliteTeamMemberTestDbContext(options))
|
||||||
|
await setup.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
await using var serviceProvider = BuildServiceProvider(connection);
|
||||||
|
await using (var createScope = serviceProvider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var service = createScope.ServiceProvider.GetRequiredService<ITeamMemberService>();
|
||||||
|
var outcome = await service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(outcome.Success);
|
||||||
|
Assert.True(outcome.Member!.PendingRegistration);
|
||||||
|
Assert.Equal("taylor@example.com", outcome.Member.Email);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var verifyScope = serviceProvider.CreateAsyncScope();
|
||||||
|
var verify = verifyScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
var user = await verify.Users.AsNoTracking().SingleAsync(user => user.Email == "taylor@example.com");
|
||||||
|
Assert.True(user.PendingRegistration);
|
||||||
|
|
||||||
|
var role = await verify.Roles.AsNoTracking().SingleAsync(role => role.Name == "Dispatcher");
|
||||||
|
Assert.Contains(await verify.UserRoles.AsNoTracking().ToListAsync(), row => row.UserId == user.Id && row.RoleId == role.Id);
|
||||||
|
|
||||||
|
var areas = await verify.UserServiceAreas.AsNoTracking()
|
||||||
|
.Where(area => area.UserId == user.Id)
|
||||||
|
.Select(area => area.Area)
|
||||||
|
.OrderBy(area => area)
|
||||||
|
.ToListAsync();
|
||||||
|
Assert.Equal(new[] { "East", "West" }, areas);
|
||||||
|
|
||||||
|
var permissionOverride = await verify.UserPermissionOverrides.AsNoTracking()
|
||||||
|
.SingleAsync(permission => permission.UserId == user.Id);
|
||||||
|
Assert.Equal("deleteSites", permissionOverride.PermissionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServiceProvider BuildServiceProvider(SqliteConnection connection, bool injectFailure = false)
|
||||||
|
{
|
||||||
|
var configuration = new ConfigurationBuilder().Build();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
services.AddDbContext<ApplicationDbContext>(builder => builder.UseSqlite(connection));
|
||||||
|
services.Replace(ServiceDescriptor.Scoped<ApplicationDbContext>(provider =>
|
||||||
|
new SqliteTeamMemberTestDbContext(
|
||||||
|
provider.GetRequiredService<DbContextOptions<ApplicationDbContext>>())));
|
||||||
|
services.AddIdentity<ApplicationUser, IdentityRole>()
|
||||||
|
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||||
|
.AddDefaultTokenProviders();
|
||||||
|
services.AddDataServices();
|
||||||
|
services.AddBusinessServices(configuration);
|
||||||
|
|
||||||
|
if (injectFailure)
|
||||||
|
{
|
||||||
|
services.AddScoped<TeamPermissionOverrideDataService>();
|
||||||
|
services.AddScoped<ITeamPermissionOverrideDataService>(provider =>
|
||||||
|
new ThrowingAfterPersistPermissionDataService(
|
||||||
|
provider.GetRequiredService<TeamPermissionOverrideDataService>(),
|
||||||
|
provider.GetRequiredService<ApplicationDbContext>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateTeamMemberRequestDTO ValidRequest() => new()
|
||||||
|
{
|
||||||
|
Name = "Taylor Dispatcher",
|
||||||
|
Role = "dispatcher",
|
||||||
|
Color = "#F59E0B",
|
||||||
|
Email = "taylor@example.com",
|
||||||
|
Phone = "555-0100",
|
||||||
|
ServiceAreas = new[] { "east", "West" },
|
||||||
|
PermissionOverrides = new Dictionary<string, UserPermissionState>
|
||||||
|
{
|
||||||
|
["deleteSites"] = UserPermissionState.Allow
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ClaimsPrincipal Admin() =>
|
||||||
|
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "test"));
|
||||||
|
|
||||||
|
private sealed class SqliteTeamMemberTestDbContext : ApplicationDbContext
|
||||||
|
{
|
||||||
|
public SqliteTeamMemberTestDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||||
|
: base(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(builder);
|
||||||
|
|
||||||
|
foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes()))
|
||||||
|
{
|
||||||
|
if (index.GetFilter() is not null)
|
||||||
|
index.SetFilter(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var property in builder.Model.GetEntityTypes()
|
||||||
|
.SelectMany(entity => entity.GetProperties())
|
||||||
|
.Where(property => property.Name == "RowVersion" && property.ClrType == typeof(byte[])))
|
||||||
|
{
|
||||||
|
property.ValueGenerated = ValueGenerated.Never;
|
||||||
|
property.IsConcurrencyToken = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ThrowingAfterPersistPermissionDataService : ITeamPermissionOverrideDataService
|
||||||
|
{
|
||||||
|
private readonly ITeamPermissionOverrideDataService _inner;
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
||||||
|
public ThrowingAfterPersistPermissionDataService(
|
||||||
|
ITeamPermissionOverrideDataService inner,
|
||||||
|
ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_inner = inner;
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public (int Users, int Roles, int UserRoles, int ServiceAreas, int PermissionOverrides)? RowsAtFailure { get; private set; }
|
||||||
|
|
||||||
|
public Task<TeamPermissionUserData?> GetUserAsync(string userId, CancellationToken cancellationToken) =>
|
||||||
|
_inner.GetUserAsync(userId, cancellationToken);
|
||||||
|
|
||||||
|
public Task SetOverrideAsync(
|
||||||
|
string userId,
|
||||||
|
string permissionKey,
|
||||||
|
UserPermissionState state,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
_inner.SetOverrideAsync(userId, permissionKey, state, cancellationToken);
|
||||||
|
|
||||||
|
public async Task SetOverridesAsync(
|
||||||
|
string userId,
|
||||||
|
IReadOnlyDictionary<string, UserPermissionState> overrides,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _inner.SetOverridesAsync(userId, overrides, cancellationToken);
|
||||||
|
RowsAtFailure = (
|
||||||
|
await _context.Users.CountAsync(cancellationToken),
|
||||||
|
await _context.Roles.CountAsync(cancellationToken),
|
||||||
|
await _context.UserRoles.CountAsync(cancellationToken),
|
||||||
|
await _context.UserServiceAreas.CountAsync(cancellationToken),
|
||||||
|
await _context.UserPermissionOverrides.CountAsync(cancellationToken));
|
||||||
|
throw new InvalidOperationException("injected mid-sequence failure");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ClearOverridesAsync(string userId, CancellationToken cancellationToken) =>
|
||||||
|
_inner.ClearOverridesAsync(userId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -442,6 +442,7 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
public Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
|
public Task<Dispatch?> GetUpliftDispatchForWorkOrderAsync(int workOrderId, int? primaryDispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,72 @@ public class WorkOrderBoardCreateRelationalTests
|
||||||
Assert.Empty(await verify.Dispatches.AsNoTracking().ToListAsync());
|
Assert.Empty(await verify.Dispatches.AsNoTracking().ToListAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WithVendor_PersistsPrimaryDispatchOwnershipForUpliftResolution()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
await using (var context = new SqliteBoardTestDbContext(options))
|
||||||
|
{
|
||||||
|
await context.Database.EnsureCreatedAsync();
|
||||||
|
context.Users.Add(new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = "actor-1",
|
||||||
|
UserName = "actor-1",
|
||||||
|
NormalizedUserName = "ACTOR-1",
|
||||||
|
Email = "actor@test.local",
|
||||||
|
NormalizedEmail = "ACTOR@TEST.LOCAL"
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
|
||||||
|
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context);
|
||||||
|
context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Acme HVAC" });
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkOrderBoardRowDto result;
|
||||||
|
await using (var context = new SqliteBoardTestDbContext(options))
|
||||||
|
{
|
||||||
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
|
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
||||||
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
|
var service = new WorkOrderBoardCreateService(
|
||||||
|
boardData,
|
||||||
|
new WorkOrderBoardMutationDataService(context),
|
||||||
|
new WorkOrderBoardService(boardData, resolver),
|
||||||
|
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks),
|
||||||
|
new WorkOrderBoardCreateValidation(),
|
||||||
|
resolver,
|
||||||
|
new WorkOrderPocDataService(context));
|
||||||
|
|
||||||
|
result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
|
VendorId = 5
|
||||||
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// SH-393: fresh context, committed state only. The primary dispatch must be owned
|
||||||
|
// by the work order so the uplift create/read scope resolves it.
|
||||||
|
await using var verify = new SqliteBoardTestDbContext(options);
|
||||||
|
var dispatch = await verify.Dispatches.AsNoTracking().SingleAsync();
|
||||||
|
Assert.Equal(result.PrimaryDispatchId, dispatch.Id);
|
||||||
|
Assert.Equal(result.Id, dispatch.WorkOrderId);
|
||||||
|
|
||||||
|
var upliftDispatch = await new UpliftDataService(verify).GetUpliftDispatchForWorkOrderAsync(
|
||||||
|
result.Id,
|
||||||
|
result.PrimaryDispatchId,
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.Equal(dispatch.Id, upliftDispatch?.Id);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class SqliteBoardTestDbContext : ApplicationDbContext
|
private sealed class SqliteBoardTestDbContext : ApplicationDbContext
|
||||||
{
|
{
|
||||||
public SqliteBoardTestDbContext(DbContextOptions<ApplicationDbContext> options)
|
public SqliteBoardTestDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,344 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Validation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SH-393: an uplift created from a work order must land on a dispatch the work order's
|
||||||
|
/// uplift reads resolve back to it (non-deleted, owned or linked), so the request is
|
||||||
|
/// listed on that work order, consumes its allowance and carries its WO number in the
|
||||||
|
/// approval queue.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WorkOrderUpliftDispatchOwnershipTests
|
||||||
|
{
|
||||||
|
private const string NoPrimaryDispatchMessage = "Work order has no primary dispatch for uplift requests";
|
||||||
|
|
||||||
|
private static ApplicationDbContext CreateContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
var context = new ApplicationDbContext(options);
|
||||||
|
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
|
||||||
|
context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Acme HVAC" });
|
||||||
|
context.SaveChanges();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderBoardCreateService NewBoardCreateService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
|
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
||||||
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
|
return new WorkOrderBoardCreateService(
|
||||||
|
boardData,
|
||||||
|
new WorkOrderBoardMutationDataService(context),
|
||||||
|
new WorkOrderBoardService(boardData, resolver),
|
||||||
|
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks),
|
||||||
|
new WorkOrderBoardCreateValidation(),
|
||||||
|
resolver,
|
||||||
|
new WorkOrderPocDataService(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderBoardUpdateService NewBoardUpdateService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
|
return new WorkOrderBoardUpdateService(
|
||||||
|
new WorkOrderBoardDataService(context),
|
||||||
|
new WorkOrderBoardMutationDataService(context),
|
||||||
|
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderUpliftService NewUpliftService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
return new WorkOrderUpliftService(
|
||||||
|
new UpliftDataService(context),
|
||||||
|
new DispatchDataService(context),
|
||||||
|
new WorkOrderDetailDataService(context),
|
||||||
|
WorkOrderAccountTestHelpers.Resolver(context),
|
||||||
|
new UserDataService(context),
|
||||||
|
TimeProvider.System,
|
||||||
|
Options.Create(new ApprovalsOptions
|
||||||
|
{
|
||||||
|
UpliftTier1MaxUsd = 2500m,
|
||||||
|
Tier1Roles = new[] { "Approver" },
|
||||||
|
Tier2Roles = new[] { "Manager" },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<WorkOrderBoardRowDto> BoardCreateWithVendorAsync(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
bool? isAddOn = null)
|
||||||
|
{
|
||||||
|
return NewBoardCreateService(context).CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
|
VendorId = 5,
|
||||||
|
IsAddOn = isAddOn,
|
||||||
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<WorkOrderUpliftDto?> CreateUpliftAsync(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
int workOrderId,
|
||||||
|
decimal amount,
|
||||||
|
int accountId = 1)
|
||||||
|
{
|
||||||
|
return NewUpliftService(context).CreateAsync(
|
||||||
|
workOrderId,
|
||||||
|
new CreateWorkOrderUpliftRequestDto { Amount = amount, Notes = "Extra coil work" },
|
||||||
|
WorkOrderAccountTestHelpers.AccountUser(accountId: accountId),
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(int WorkOrderId, int PrimaryDispatchId)> SeedWorkOrderAsync(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
Dispatch primary,
|
||||||
|
params Dispatch[] others)
|
||||||
|
{
|
||||||
|
context.Dispatches.Add(primary);
|
||||||
|
context.Dispatches.AddRange(others);
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
InternalWONumber = "SH00001",
|
||||||
|
AccountId = 1,
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
PrimaryDispatchId = primary.Id,
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
return (1, primary.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoardCreate_WithVendor_PrimaryDispatchBelongsToWorkOrder()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
|
||||||
|
var row = await BoardCreateWithVendorAsync(context);
|
||||||
|
|
||||||
|
var dispatch = await context.Dispatches.SingleAsync(d => d.Id == row.PrimaryDispatchId);
|
||||||
|
Assert.Equal(row.Id, dispatch.WorkOrderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoardCreatedWorkOrder_PatchApptDate_KeepsPrimaryDispatch()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var row = await BoardCreateWithVendorAsync(context);
|
||||||
|
var workOrder = await context.workOrders.SingleAsync(w => w.Id == row.Id);
|
||||||
|
var dispatch = await context.Dispatches.SingleAsync(d => d.Id == row.PrimaryDispatchId);
|
||||||
|
workOrder.RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 };
|
||||||
|
dispatch.RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 };
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var patched = await NewBoardUpdateService(context).PatchFieldAsync(row.Id, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.ApptDate,
|
||||||
|
Value = "2026-10-05",
|
||||||
|
WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion),
|
||||||
|
DispatchVersion = Convert.ToBase64String(dispatch.RowVersion),
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.Equal(row.PrimaryDispatchId, patched.PrimaryDispatchId);
|
||||||
|
Assert.Equal(1, await context.Dispatches.CountAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoardCreatedWorkOrder_AutoApprovedUplift_IsListedAndConsumesAllowance()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var row = await BoardCreateWithVendorAsync(context);
|
||||||
|
|
||||||
|
var created = await CreateUpliftAsync(context, row.Id, 400m);
|
||||||
|
|
||||||
|
Assert.Equal("auto_approved", created!.Status);
|
||||||
|
var listed = await NewUpliftService(context).ListAsync(
|
||||||
|
row.Id,
|
||||||
|
WorkOrderAccountTestHelpers.AccountUser(),
|
||||||
|
CancellationToken.None);
|
||||||
|
var uplift = Assert.Single(listed!);
|
||||||
|
Assert.Equal(created.Id, uplift.Id);
|
||||||
|
Assert.Equal(400m, await new UpliftDataService(context)
|
||||||
|
.SumAutoApprovedAmountForWorkOrderAsync(row.Id, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoardCreatedWorkOrder_RequestBeyondRemainingAllowance_GoesToPendingWithWorkOrderNumber()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var row = await BoardCreateWithVendorAsync(context);
|
||||||
|
await CreateUpliftAsync(context, row.Id, 400m);
|
||||||
|
|
||||||
|
var second = await CreateUpliftAsync(context, row.Id, 200m);
|
||||||
|
|
||||||
|
Assert.Equal("pending", second!.Status);
|
||||||
|
var (_, queue) = await new UpliftDataService(context)
|
||||||
|
.GetPagedAsync("Pending", null, 1, 25, CancellationToken.None);
|
||||||
|
var queued = Assert.Single(queue);
|
||||||
|
Assert.Equal(second.Id, queued.Id);
|
||||||
|
Assert.Equal(row.Id, queued.WorkOrderId);
|
||||||
|
Assert.Equal(row.WoNumber, queued.WorkOrderNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoardCreatedWorkOrder_OwnerRevokesAutoApprovedUplift()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var row = await BoardCreateWithVendorAsync(context);
|
||||||
|
var created = await CreateUpliftAsync(context, row.Id, 1m);
|
||||||
|
|
||||||
|
var revoked = await NewUpliftService(context).RevokeAsync(
|
||||||
|
row.Id,
|
||||||
|
created!.Id,
|
||||||
|
new RevokeWorkOrderUpliftRequestDto(),
|
||||||
|
WorkOrderAccountTestHelpers.AccountUser(),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("revoked", revoked!.Status);
|
||||||
|
Assert.Equal(0m, await new UpliftDataService(context)
|
||||||
|
.SumAutoApprovedAmountForWorkOrderAsync(row.Id, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExAddOnWorkOrder_AfterScheduleClearsAddOn_UpliftIsListedOnWorkOrder()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var row = await BoardCreateWithVendorAsync(context, isAddOn: true);
|
||||||
|
Assert.True(row.IsAddOn);
|
||||||
|
var workOrder = await context.workOrders.SingleAsync(w => w.Id == row.Id);
|
||||||
|
workOrder.RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 };
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var rescheduled = await NewBoardUpdateService(context).PatchFieldAsync(row.Id, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.ScheduledDate,
|
||||||
|
Value = DateTime.UtcNow.Date.AddDays(60).ToString("yyyy-MM-dd"),
|
||||||
|
WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion),
|
||||||
|
}, "actor-1");
|
||||||
|
Assert.False(rescheduled.IsAddOn);
|
||||||
|
|
||||||
|
var created = await CreateUpliftAsync(context, row.Id, 1m);
|
||||||
|
|
||||||
|
var listed = await NewUpliftService(context).ListAsync(
|
||||||
|
row.Id,
|
||||||
|
WorkOrderAccountTestHelpers.AccountUser(),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.Equal(created!.Id, Assert.Single(listed!).Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_SoftDeletedPrimaryDispatch_UsesOwnedLiveDispatch()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var (workOrderId, _) = await SeedWorkOrderAsync(
|
||||||
|
context,
|
||||||
|
new Dispatch { Id = 10, VendorId = 5, WorkOrderId = 1, NTEAmount = 1000m, IsDeleted = true },
|
||||||
|
new Dispatch { Id = 11, VendorId = 5, WorkOrderId = 1, NTEAmount = 800m });
|
||||||
|
|
||||||
|
var created = await CreateUpliftAsync(context, workOrderId, 100m);
|
||||||
|
|
||||||
|
var request = await context.DispatchUpliftRequests.SingleAsync(u => u.Id == created!.Id);
|
||||||
|
Assert.Equal(11, request.DispatchId);
|
||||||
|
Assert.Equal(1000m, (await context.Dispatches.SingleAsync(d => d.Id == 10)).NTEAmount);
|
||||||
|
Assert.Equal(900m, (await context.Dispatches.SingleAsync(d => d.Id == 11)).NTEAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_OnlySoftDeletedPrimaryDispatch_IsRejectedWithoutPersisting()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var (workOrderId, _) = await SeedWorkOrderAsync(
|
||||||
|
context,
|
||||||
|
new Dispatch { Id = 10, VendorId = 5, WorkOrderId = 1, NTEAmount = 1000m, IsDeleted = true });
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => CreateUpliftAsync(context, workOrderId, 100m));
|
||||||
|
|
||||||
|
Assert.Equal(NoPrimaryDispatchMessage, error.Message);
|
||||||
|
Assert.Empty(context.DispatchUpliftRequests);
|
||||||
|
Assert.Empty(context.WorkOrderAuditLogs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_PrimaryDispatchOwnedByAnotherWorkOrder_IsNotUsed()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
context.workOrders.Add(new WorkOrder { Id = 2, InternalWONumber = "SH00002", AccountId = 1 });
|
||||||
|
var (workOrderId, _) = await SeedWorkOrderAsync(
|
||||||
|
context,
|
||||||
|
new Dispatch { Id = 10, VendorId = 5, WorkOrderId = 2, NTEAmount = 1000m });
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => CreateUpliftAsync(context, workOrderId, 100m));
|
||||||
|
|
||||||
|
Assert.Equal(NoPrimaryDispatchMessage, error.Message);
|
||||||
|
Assert.Empty(context.DispatchUpliftRequests);
|
||||||
|
Assert.Equal(1000m, (await context.Dispatches.SingleAsync(d => d.Id == 10)).NTEAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_UnlinkedPrimaryDispatch_IsRejectedInsteadOfHidingTheUplift()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var (workOrderId, _) = await SeedWorkOrderAsync(
|
||||||
|
context,
|
||||||
|
new Dispatch { Id = 10, VendorId = 5, WorkOrderId = null, NTEAmount = 1000m });
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => CreateUpliftAsync(context, workOrderId, 100m));
|
||||||
|
|
||||||
|
Assert.Equal(NoPrimaryDispatchMessage, error.Message);
|
||||||
|
Assert.Empty(context.DispatchUpliftRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_PrimaryDispatchLinkedThroughDispatchWorkOrders_IsUsed()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
context.workOrders.Add(new WorkOrder { Id = 2, InternalWONumber = "SH00002", AccountId = 1 });
|
||||||
|
var (workOrderId, _) = await SeedWorkOrderAsync(
|
||||||
|
context,
|
||||||
|
new Dispatch { Id = 10, VendorId = 5, WorkOrderId = 2, NTEAmount = 1000m });
|
||||||
|
context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = 10, WorkOrderId = workOrderId });
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var created = await CreateUpliftAsync(context, workOrderId, 100m);
|
||||||
|
|
||||||
|
var listed = await NewUpliftService(context).ListAsync(
|
||||||
|
workOrderId,
|
||||||
|
WorkOrderAccountTestHelpers.AccountUser(),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.Equal(created!.Id, Assert.Single(listed!).Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CrossTenant_CreateAndList_AreRejected()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, id: 2, name: "Other Corp");
|
||||||
|
var row = await BoardCreateWithVendorAsync(context);
|
||||||
|
await CreateUpliftAsync(context, row.Id, 1m);
|
||||||
|
|
||||||
|
var created = await CreateUpliftAsync(context, row.Id, 1m, accountId: 2);
|
||||||
|
var listed = await NewUpliftService(context).ListAsync(
|
||||||
|
row.Id,
|
||||||
|
WorkOrderAccountTestHelpers.AccountUser(accountId: 2),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Null(created);
|
||||||
|
Assert.Null(listed);
|
||||||
|
Assert.Equal(1, await context.DispatchUpliftRequests.CountAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -289,7 +289,21 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
{
|
{
|
||||||
await using var context = CreateContext();
|
await using var context = CreateContext();
|
||||||
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
|
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
|
||||||
dispatch.WorkOrderId = null;
|
// SH-393: an unlinked primary is no longer an uplift target, so the shared-dispatch
|
||||||
|
// case (owned by another work order, linked to this one) carries the duplicate check.
|
||||||
|
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,
|
||||||
|
});
|
||||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||||
{
|
{
|
||||||
Id = 100,
|
Id = 100,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue