mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
Compare commits
22 commits
7b7df4463e
...
50e5553e57
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50e5553e57 | ||
|
|
b2b9fc3588 | ||
|
|
59b8cdaf7d | ||
|
|
c5d82a996a | ||
|
|
8185875ad2 | ||
|
|
beb091fcc8 | ||
|
|
1e2f39a5fa | ||
|
|
7591869462 | ||
|
|
26b4aaf1b2 | ||
|
|
cfb05a906f | ||
|
|
b116e71d16 | ||
|
|
cc7cda4818 | ||
|
|
2be74b261f | ||
|
|
24ad21d7f2 | ||
|
|
0ca9b767ed | ||
|
|
8af9ad076f | ||
|
|
0b39d92723 | ||
|
|
50a2cbab5f | ||
|
|
e1632bc3bf | ||
|
|
f4284badf2 | ||
|
|
ff6a5945f1 | ||
|
|
9156107e72 |
21 changed files with 1265 additions and 25 deletions
206
Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs
Normal file
206
Api.SeaHavenIndustries.Tests/UpliftAdminApprovalTests.cs
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
// Admin passes every permission check regardless of stored configuration.
|
||||
// Uplift approval authority is otherwise driven by Approvals:Tier1Roles/Tier2Roles,
|
||||
// which may be empty in a deployed environment; Admin must still be able to decide.
|
||||
public sealed class UpliftAdminApprovalTests
|
||||
{
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal UserWithRoles(params string[] roles)
|
||||
{
|
||||
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, "user-42") };
|
||||
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
|
||||
}
|
||||
|
||||
private static UpliftService NewService(ApplicationDbContext context, ApprovalsOptions? options = null) =>
|
||||
new(new UpliftDataService(context),
|
||||
new DispatchDataService(context),
|
||||
new NoopDocumentStorage(),
|
||||
TimeProvider.System,
|
||||
Microsoft.Extensions.Options.Options.Create(options ?? new ApprovalsOptions()));
|
||||
|
||||
private static async Task<Dispatch> SeedPendingAsync(ApplicationDbContext context, int requiredTier)
|
||||
{
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
Status = "Completed",
|
||||
NTEAmount = 1000m,
|
||||
DispatchNumber = "DIS-1"
|
||||
};
|
||||
context.Dispatches.Add(dispatch);
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 1800m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = requiredTier,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
return dispatch;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public void CanApprove_Admin_WithEmptyTierConfig_IsTrue(int tier)
|
||||
{
|
||||
using var context = NewContext();
|
||||
var service = NewService(context);
|
||||
|
||||
service.CanApprove(UserWithRoles("Admin"), tier).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public async Task List_Admin_WithEmptyTierConfig_CanDecidePendingRows(int tier)
|
||||
{
|
||||
using var context = NewContext();
|
||||
var dispatch = await SeedPendingAsync(context, tier);
|
||||
var service = NewService(context);
|
||||
|
||||
var queue = await service.ListAsync(UserWithRoles("Admin"), UpliftStatus.Pending, null, 1, 25, CancellationToken.None);
|
||||
var forDispatch = await service.ListForDispatchAsync(UserWithRoles("Admin"), dispatch.Id, CancellationToken.None);
|
||||
|
||||
queue.Items.Should().ContainSingle().Which.CanDecide.Should().BeTrue();
|
||||
forDispatch.Should().ContainSingle().Which.CanDecide.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public async Task Approve_Admin_WithEmptyTierConfig_Succeeds(int tier)
|
||||
{
|
||||
using var context = NewContext();
|
||||
await SeedPendingAsync(context, tier);
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ApproveAsync(UserWithRoles("Admin"), 1, "ok", CancellationToken.None);
|
||||
|
||||
result.Status.Should().Be(UpliftStatus.Approved);
|
||||
context.Dispatches.Single().NTEAmount.Should().Be(1800m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reject_Admin_WithEmptyTierConfig_Succeeds()
|
||||
{
|
||||
using var context = NewContext();
|
||||
await SeedPendingAsync(context, 2);
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.RejectAsync(UserWithRoles("Admin"), 1, "no", CancellationToken.None);
|
||||
|
||||
result.Status.Should().Be(UpliftStatus.Rejected);
|
||||
context.Dispatches.Single().NTEAmount.Should().Be(1000m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestChanges_Admin_WithEmptyTierConfig_Succeeds()
|
||||
{
|
||||
using var context = NewContext();
|
||||
await SeedPendingAsync(context, 2);
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.RequestChangesAsync(UserWithRoles("Admin"), 1, "more detail", CancellationToken.None);
|
||||
|
||||
result.Status.Should().Be(UpliftStatus.ChangesRequested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvidenceDownload_Admin_WithEmptyTierConfig_ReturnsFile()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var dispatch = await SeedPendingAsync(context, 2);
|
||||
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
|
||||
{
|
||||
VendorId = dispatch.VendorId,
|
||||
DispatchId = dispatch.Id,
|
||||
WorkOrderId = dispatch.WorkOrderId!.Value,
|
||||
OriginalFileName = "invoice.pdf",
|
||||
StoredFileName = "evidence.bin",
|
||||
ContentType = "application/pdf",
|
||||
SizeBytes = 4,
|
||||
ScanStatus = "Passed",
|
||||
ReviewStatus = "Approved",
|
||||
Purpose = "UpliftEvidence",
|
||||
Version = 1
|
||||
});
|
||||
context.DispatchUpliftRequests.Single().EvidenceDocumentId = 1;
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Admin"), 1, CancellationToken.None);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok);
|
||||
result.FileName.Should().Be("invoice.pdf");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Dispatcher")]
|
||||
[InlineData("Scheduler")]
|
||||
public async Task NonAdminRole_WithEmptyTierConfig_IsStillDenied(string role)
|
||||
{
|
||||
using var context = NewContext();
|
||||
await SeedPendingAsync(context, 1);
|
||||
var service = NewService(context);
|
||||
var user = UserWithRoles(role);
|
||||
|
||||
service.CanApprove(user, 1).Should().BeFalse();
|
||||
service.CanApprove(user, 2).Should().BeFalse();
|
||||
var queue = await service.ListAsync(user, UpliftStatus.Pending, null, 1, 25, CancellationToken.None);
|
||||
queue.Items.Should().ContainSingle().Which.CanDecide.Should().BeFalse();
|
||||
|
||||
var act = () => service.ApproveAsync(user, 1, "ok", CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<UpliftForbiddenException>();
|
||||
context.Dispatches.Single().NTEAmount.Should().Be(1000m);
|
||||
context.DispatchUpliftRequests.Single().Status.Should().Be(UpliftStatus.Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tier1OnlyRole_ApprovesTier1_ButNotTier2()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var service = NewService(context, new ApprovalsOptions { Tier1Roles = new[] { "Approver" } });
|
||||
var user = UserWithRoles("Approver");
|
||||
|
||||
service.CanApprove(user, 1).Should().BeTrue();
|
||||
service.CanApprove(user, 2).Should().BeFalse();
|
||||
}
|
||||
|
||||
private sealed class NoopDocumentStorage : IVendorDocumentStoragePort
|
||||
{
|
||||
public Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Stream OpenRead(int vendorId, int dispatchId, string storedFileName) => new MemoryStream();
|
||||
public void Delete(int vendorId, int dispatchId, string storedFileName) { }
|
||||
}
|
||||
}
|
||||
|
|
@ -139,7 +139,8 @@ public sealed class UpliftWorkflowTests
|
|||
private static VendorPortalService NewPortalService(
|
||||
ApplicationDbContext context,
|
||||
IEmailSender emailSender,
|
||||
IVendorDocumentStoragePort? storage = null)
|
||||
IVendorDocumentStoragePort? storage = null,
|
||||
IUpliftDataService? upliftData = null)
|
||||
{
|
||||
var vendorData = new VendorDataService(context);
|
||||
var tokenService = new VendorPortalTokenService(vendorData, Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions()));
|
||||
|
|
@ -150,7 +151,7 @@ public sealed class UpliftWorkflowTests
|
|||
return new VendorPortalService(
|
||||
tokenService,
|
||||
new DispatchDataService(context),
|
||||
new UpliftDataService(context),
|
||||
upliftData ?? new UpliftDataService(context),
|
||||
commentData.Object,
|
||||
Mock.Of<IUserDataService>(),
|
||||
emailSender,
|
||||
|
|
@ -214,6 +215,36 @@ public sealed class UpliftWorkflowTests
|
|||
result.RequiredTier.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestUplift_ConcurrentActiveRequest_MapsPersistenceConflict()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var upliftData = new Mock<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 ---
|
||||
|
||||
[Fact]
|
||||
|
|
@ -650,6 +681,8 @@ public sealed class UpliftWorkflowTests
|
|||
var upliftData = new Mock<IUpliftDataService>();
|
||||
upliftData.Setup(u => u.GetDueForExpiryAsync(It.IsAny<DateTime>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<DispatchUpliftRequest> { orphanReq, validReq });
|
||||
upliftData.Setup(u => u.GetWorkOrderIdForUpliftAsync(202, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(99);
|
||||
upliftData.Setup(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
|
|
@ -309,6 +310,34 @@ public class VendorPortalControllerTests
|
|||
result.Should().BeOfType<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]
|
||||
public async Task RequestUplift_Success_ReturnsResult()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
using Xunit;
|
||||
|
|
@ -78,6 +79,75 @@ public sealed class WorkOrderUpliftControllerTests
|
|||
Assert.Equal(12, created.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateUplift_RequestPermissionDenied_ReturnsExact403Message()
|
||||
{
|
||||
var service = new Mock<IWorkOrderUpliftService>();
|
||||
service.Setup(x => x.CreateAsync(
|
||||
7,
|
||||
It.IsAny<CreateWorkOrderUpliftRequestDto>(),
|
||||
It.IsAny<ClaimsPrincipal>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new UpliftForbiddenException(UpliftForbiddenException.RequestUpliftsDeniedMessage));
|
||||
|
||||
var controller = NewController(service, "Dispatcher");
|
||||
var result = await controller.CreateUplift(
|
||||
7,
|
||||
new CreateWorkOrderUpliftRequestDto { Amount = 750m },
|
||||
CancellationToken.None);
|
||||
|
||||
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
|
||||
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
var response = forbidden.Value.Should().BeOfType<Response>().Subject;
|
||||
response.Message.Should().Be(UpliftForbiddenException.RequestUpliftsDeniedMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateUplift_UnrelatedForbiddenException_ReturnsSanitized403()
|
||||
{
|
||||
var service = new Mock<IWorkOrderUpliftService>();
|
||||
service.Setup(x => x.CreateAsync(
|
||||
7,
|
||||
It.IsAny<CreateWorkOrderUpliftRequestDto>(),
|
||||
It.IsAny<ClaimsPrincipal>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new UpliftForbiddenException("SECRET-internal-tier-detail"));
|
||||
|
||||
var controller = NewController(service, "Dispatcher");
|
||||
var result = await controller.CreateUplift(
|
||||
7,
|
||||
new CreateWorkOrderUpliftRequestDto { Amount = 750m },
|
||||
CancellationToken.None);
|
||||
|
||||
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
|
||||
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
var response = forbidden.Value.Should().BeOfType<Response>().Subject;
|
||||
response.Message.Should().NotContain("SECRET-internal-tier-detail");
|
||||
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]
|
||||
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.Extensions.Logging;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
|
|
@ -217,6 +218,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
var result = await _portalService.RequestUpliftAsync(session, id, body?.RequestedNTE ?? 0m, body?.Reason, body?.RequestKey, body?.EvidenceDocumentId, cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||
}
|
||||
catch (UpliftConflictException ex)
|
||||
{
|
||||
return Conflict(new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
|
|
|
|||
|
|
@ -164,13 +164,20 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
||||
return Ok(new DataResponse { Status = "Success", Data = created });
|
||||
}
|
||||
catch (UpliftConflictException ex)
|
||||
{
|
||||
return Conflict(new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Resource not found") });
|
||||
}
|
||||
catch (UpliftForbiddenException ex)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to perform this action") });
|
||||
var message = ex.Message == UpliftForbiddenException.RequestUpliftsDeniedMessage
|
||||
? UpliftForbiddenException.RequestUpliftsDeniedMessage
|
||||
: _logger.Sanitize(ex, "You are not authorized to perform this action");
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Exceptions;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
|
|
@ -544,7 +545,58 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task SaveChangesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException exception) when (IsActiveDispatchUniqueIndexViolation(exception))
|
||||
{
|
||||
throw new UpliftDispatchConflictException();
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsActiveDispatchUniqueIndexViolation(DbUpdateException exception)
|
||||
{
|
||||
var provider = _context.Database.ProviderName;
|
||||
var databaseException = exception.GetBaseException();
|
||||
var exceptionType = databaseException.GetType();
|
||||
|
||||
if (provider?.Contains("SqlServer", StringComparison.OrdinalIgnoreCase) == true
|
||||
&& exceptionType.FullName == "Microsoft.Data.SqlClient.SqlException")
|
||||
{
|
||||
var number = exceptionType.GetProperty("Number")?.GetValue(databaseException) as int?;
|
||||
return number is 2601 or 2627
|
||||
&& 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>(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Api.SeaHavenIndustries.Tests")]
|
||||
[assembly: InternalsVisibleTo("SeaHavenIndustries.Tests")]
|
||||
|
|
|
|||
|
|
@ -104,6 +104,9 @@ namespace SeaHaven.Services.DTOs
|
|||
|
||||
public class UpliftForbiddenException : Exception
|
||||
{
|
||||
public const string RequestUpliftsDeniedMessage =
|
||||
"Your role can't request uplifts on this work order.";
|
||||
|
||||
public UpliftForbiddenException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
|
|
|
|||
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.")
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -61,9 +61,9 @@ namespace SeaHaven.Services.Implementation
|
|||
req.DecidedAt = now;
|
||||
req.LastModificationTime = now;
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
await StageAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch?.WorkOrderId ?? 0,
|
||||
WorkOrderId = workOrderId,
|
||||
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
|
||||
OldValue = previous,
|
||||
NewValue = UpliftStatus.Expired,
|
||||
|
|
@ -136,9 +136,9 @@ namespace SeaHaven.Services.Implementation
|
|||
req.EscalatedAt = now;
|
||||
req.LastModificationTime = now;
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
await StageAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
WorkOrderId = workOrderId,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
|
||||
OldValue = "Pending",
|
||||
NewValue = "Escalated",
|
||||
|
|
@ -247,5 +247,19 @@ namespace SeaHaven.Services.Implementation
|
|||
req.LastModificationTime = now;
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// The audit goes to the work order the uplift's dispatch belongs to (owner, else the
|
||||
// one it is linked to). With neither, the status change is recorded on the request
|
||||
// alone; a work-order id of 0 would fail the required audit foreign key and abort
|
||||
// the sweep on every run.
|
||||
private async Task StageAuditAsync(
|
||||
int upliftId,
|
||||
Func<int, WorkOrderAuditLog> buildEntry,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workOrderId = await _upliftData.GetWorkOrderIdForUpliftAsync(upliftId, cancellationToken);
|
||||
if (workOrderId is int resolved)
|
||||
await _dispatchData.StageAuditLogAsync(buildEntry(resolved), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,9 +166,9 @@ namespace SeaHaven.Services.Implementation
|
|||
req.DecisionNote = string.IsNullOrWhiteSpace(note) ? null : note!.Trim();
|
||||
req.LastModificationTime = now;
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
await StageDecisionAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
WorkOrderId = workOrderId,
|
||||
UserId = userId,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} NTE",
|
||||
OldValue = $"${oldNTE:F2}",
|
||||
|
|
@ -252,9 +252,9 @@ namespace SeaHaven.Services.Implementation
|
|||
req.DecisionNote = note!.Trim();
|
||||
req.LastModificationTime = now;
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
await StageDecisionAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch?.WorkOrderId ?? 0,
|
||||
WorkOrderId = workOrderId,
|
||||
UserId = userId,
|
||||
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
|
||||
OldValue = previous,
|
||||
|
|
@ -290,9 +290,9 @@ namespace SeaHaven.Services.Implementation
|
|||
req.DecisionNote = note.Trim();
|
||||
req.LastModificationTime = now;
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
await StageDecisionAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch?.WorkOrderId ?? 0,
|
||||
WorkOrderId = workOrderId,
|
||||
UserId = userId,
|
||||
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
|
||||
OldValue = previous,
|
||||
|
|
@ -307,6 +307,19 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
public bool CanApprove(ClaimsPrincipal user, int tier) => UserCanApprove(user, tier);
|
||||
|
||||
// The decision audit goes to the work order the uplift's dispatch belongs to (owner,
|
||||
// else the one it is linked to). A dispatch with neither has no work order to audit
|
||||
// against, so the decision is recorded on the request alone.
|
||||
private async Task StageDecisionAuditAsync(
|
||||
int upliftId,
|
||||
Func<int, WorkOrderAuditLog> buildEntry,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workOrderId = await _upliftData.GetWorkOrderIdForUpliftAsync(upliftId, cancellationToken);
|
||||
if (workOrderId is int resolved)
|
||||
await _dispatchData.StageAuditLogAsync(buildEntry(resolved), cancellationToken);
|
||||
}
|
||||
|
||||
// SH-101: authorized internal download of a Passed UpliftEvidence file. The data
|
||||
// service resolves the document by the request's own linkage (no client-supplied
|
||||
// document id or vendor/public path). Only UpliftEvidence documents are exposed;
|
||||
|
|
@ -335,8 +348,12 @@ namespace SeaHaven.Services.Implementation
|
|||
return UpliftEvidenceDownloadResultDTO.Ok(content, evidence.ContentType ?? "application/octet-stream", evidence.OriginalFileName ?? "evidence");
|
||||
}
|
||||
|
||||
// Admin passes every permission check regardless of stored configuration,
|
||||
// so the tier-role lists only govern non-Admin approvers.
|
||||
private bool UserCanApprove(ClaimsPrincipal user, int requiredTier)
|
||||
{
|
||||
if (user.IsInRole("Admin")) return true;
|
||||
|
||||
var roles = RolesForTier(requiredTier);
|
||||
foreach (var r in roles)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using Microsoft.Extensions.Options;
|
|||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
|
|
@ -651,7 +652,14 @@ namespace SeaHaven.Services.Implementation
|
|||
CreatedAt = now
|
||||
}, cancellationToken);
|
||||
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException)
|
||||
{
|
||||
throw new UpliftConflictException();
|
||||
}
|
||||
|
||||
// Notification is best-effort and persisted separately from workflow state: a failure
|
||||
// never destroys the actionable request (the lifecycle sweep retries by sentinel).
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using Data.SeaHavenIndustries.Enums;
|
|||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
|
|
@ -19,6 +20,8 @@ namespace SeaHaven.Services.Implementation
|
|||
private readonly IWorkOrderDetailDataService _detailData;
|
||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||
private readonly IUserDataService _userData;
|
||||
private readonly ITeamPermissionOverrideDataService _permissionOverrideData;
|
||||
private readonly ITeamPermissionPolicy _permissionPolicy;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ApprovalsOptions _approvalsOptions;
|
||||
|
||||
|
|
@ -28,6 +31,8 @@ namespace SeaHaven.Services.Implementation
|
|||
IWorkOrderDetailDataService detailData,
|
||||
IWorkOrderAccountResolver accountResolver,
|
||||
IUserDataService userData,
|
||||
ITeamPermissionOverrideDataService permissionOverrideData,
|
||||
ITeamPermissionPolicy permissionPolicy,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<ApprovalsOptions> approvalsOptions)
|
||||
{
|
||||
|
|
@ -36,6 +41,8 @@ namespace SeaHaven.Services.Implementation
|
|||
_detailData = detailData;
|
||||
_accountResolver = accountResolver;
|
||||
_userData = userData;
|
||||
_permissionOverrideData = permissionOverrideData;
|
||||
_permissionPolicy = permissionPolicy;
|
||||
_timeProvider = timeProvider;
|
||||
_approvalsOptions = approvalsOptions.Value;
|
||||
}
|
||||
|
|
@ -61,6 +68,8 @@ namespace SeaHaven.Services.Implementation
|
|||
if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken))
|
||||
return null;
|
||||
|
||||
await EnsureCanRequestUpliftAsync(user, cancellationToken);
|
||||
|
||||
if (request.Amount <= 0)
|
||||
throw new InvalidOperationException("Uplift amount must be greater than zero");
|
||||
|
||||
|
|
@ -69,17 +78,49 @@ namespace SeaHaven.Services.Implementation
|
|||
var notes = request.Notes?.Trim() ?? "";
|
||||
var accountFilter = _accountResolver.ResolveAccountFilter(user);
|
||||
|
||||
return await _upliftData.ExecuteWorkOrderMutationAsync(
|
||||
workOrderId,
|
||||
ct => CreateLockedAsync(
|
||||
try
|
||||
{
|
||||
return await _upliftData.ExecuteWorkOrderMutationAsync(
|
||||
workOrderId,
|
||||
request.Amount,
|
||||
notes,
|
||||
userId,
|
||||
requesterName,
|
||||
accountFilter,
|
||||
ct),
|
||||
cancellationToken);
|
||||
ct => CreateLockedAsync(
|
||||
workOrderId,
|
||||
request.Amount,
|
||||
notes,
|
||||
userId,
|
||||
requesterName,
|
||||
accountFilter,
|
||||
ct),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException)
|
||||
{
|
||||
throw new UpliftConflictException();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureCanRequestUpliftAsync(
|
||||
ClaimsPrincipal user,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var permissionUser = string.IsNullOrWhiteSpace(userId)
|
||||
? null
|
||||
: await _permissionOverrideData.GetUserAsync(userId, cancellationToken);
|
||||
|
||||
if (permissionUser is null)
|
||||
{
|
||||
throw new UpliftForbiddenException(
|
||||
UpliftForbiddenException.RequestUpliftsDeniedMessage);
|
||||
}
|
||||
|
||||
if (!_permissionPolicy.IsAllowed(
|
||||
permissionUser.RoleName,
|
||||
TeamPermissionKeys.RequestUplifts,
|
||||
permissionUser.Overrides))
|
||||
{
|
||||
throw new UpliftForbiddenException(
|
||||
UpliftForbiddenException.RequestUpliftsDeniedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<WorkOrderUpliftDto?> CreateLockedAsync(
|
||||
|
|
|
|||
|
|
@ -9,6 +9,117 @@ namespace SeaHavenIndustries.Tests;
|
|||
|
||||
public sealed class UpliftDataServiceTransactionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Cannot insert duplicate key row with unique index 'IX_DispatchUpliftRequests_DispatchId'.", true)]
|
||||
[InlineData("Cannot insert duplicate key row with unique index 'IX_DispatchUpliftRequests_DispatchId_RequestKey'.", false)]
|
||||
public void IsActiveDispatchIndexViolationMessage_MatchesOnlyTheActiveDispatchIndex(
|
||||
string message,
|
||||
bool expected)
|
||||
{
|
||||
Assert.Equal(expected, UpliftDataService.IsActiveDispatchIndexViolationMessage(message));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveChangesAsync_ActiveUpliftOnSameDispatch_MapsConcurrentInsertConflict()
|
||||
{
|
||||
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]
|
||||
public async Task ExecuteWorkOrderMutationAsync_ReleasesGate_WhenTransactionInitializationFails()
|
||||
{
|
||||
|
|
|
|||
332
SeaHavenIndustries.Tests/UpliftDecisionAuditRelationalTests.cs
Normal file
332
SeaHavenIndustries.Tests/UpliftDecisionAuditRelationalTests.cs
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
// Relational (SQLite, foreign keys on) coverage for the work-order audit entry written by
|
||||
// uplift decisions. WorkOrderAuditLogs.WorkOrderId is a required FK, so the in-memory
|
||||
// provider cannot prove a decision on a dispatch without an owning work order commits.
|
||||
// The audit goes to the work order the dispatch belongs to (owner, else the one it is
|
||||
// linked to through DispatchWorkOrders); when none resolves, the decision is still saved
|
||||
// and no audit row is written.
|
||||
public sealed class UpliftDecisionAuditRelationalTests
|
||||
{
|
||||
private const string AdminId = "admin-1";
|
||||
|
||||
public enum Ownership
|
||||
{
|
||||
Owned,
|
||||
Linked,
|
||||
Unresolvable
|
||||
}
|
||||
|
||||
public enum Decision
|
||||
{
|
||||
Approve,
|
||||
Reject,
|
||||
RequestChanges
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Decision.Approve, Ownership.Owned)]
|
||||
[InlineData(Decision.Approve, Ownership.Linked)]
|
||||
[InlineData(Decision.Approve, Ownership.Unresolvable)]
|
||||
[InlineData(Decision.Reject, Ownership.Owned)]
|
||||
[InlineData(Decision.Reject, Ownership.Linked)]
|
||||
[InlineData(Decision.Reject, Ownership.Unresolvable)]
|
||||
[InlineData(Decision.RequestChanges, Ownership.Owned)]
|
||||
[InlineData(Decision.RequestChanges, Ownership.Linked)]
|
||||
[InlineData(Decision.RequestChanges, Ownership.Unresolvable)]
|
||||
public async Task Decision_CommitsAndAuditsResolvedWorkOrder(Decision decision, Ownership ownership)
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(connection).Options;
|
||||
|
||||
int upliftId;
|
||||
int? expectedWorkOrderId;
|
||||
await using (var seed = new SqliteUpliftDecisionTestDbContext(options))
|
||||
{
|
||||
await seed.Database.EnsureCreatedAsync();
|
||||
(upliftId, expectedWorkOrderId) = await SeedAsync(seed, ownership, expiresAt: null);
|
||||
}
|
||||
|
||||
await using (var context = new SqliteUpliftDecisionTestDbContext(options))
|
||||
{
|
||||
var service = new UpliftService(
|
||||
new UpliftDataService(context),
|
||||
new DispatchDataService(context),
|
||||
new NoDocumentStorage(),
|
||||
TimeProvider.System,
|
||||
Options.Create(new ApprovalsOptions()));
|
||||
var admin = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, AdminId),
|
||||
new Claim(ClaimTypes.Role, "Admin")
|
||||
}, "Test"));
|
||||
|
||||
switch (decision)
|
||||
{
|
||||
case Decision.Approve:
|
||||
await service.ApproveAsync(admin, upliftId, null, CancellationToken.None);
|
||||
break;
|
||||
case Decision.Reject:
|
||||
await service.RejectAsync(admin, upliftId, "too high", CancellationToken.None);
|
||||
break;
|
||||
case Decision.RequestChanges:
|
||||
await service.RequestChangesAsync(admin, upliftId, "send a quote", CancellationToken.None);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await using var verify = new SqliteUpliftDecisionTestDbContext(options);
|
||||
var saved = await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == upliftId);
|
||||
var expectedStatus = decision switch
|
||||
{
|
||||
Decision.Approve => UpliftStatus.Approved,
|
||||
Decision.Reject => UpliftStatus.Rejected,
|
||||
_ => UpliftStatus.ChangesRequested
|
||||
};
|
||||
Assert.Equal(expectedStatus, saved.Status);
|
||||
Assert.Equal(AdminId, saved.DecidedByUserId);
|
||||
if (decision == Decision.Approve)
|
||||
{
|
||||
var dispatch = await verify.Dispatches.AsNoTracking().SingleAsync(d => d.Id == saved.DispatchId);
|
||||
Assert.Equal(5_000m, dispatch.NTEAmount);
|
||||
}
|
||||
|
||||
var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync();
|
||||
Assert.DoesNotContain(audits, a => a.WorkOrderId == 0);
|
||||
if (expectedWorkOrderId is int workOrderId)
|
||||
{
|
||||
var audit = Assert.Single(audits);
|
||||
Assert.Equal(workOrderId, audit.WorkOrderId);
|
||||
Assert.StartsWith("uplift_", audit.Action);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Empty(audits);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Ownership.Linked)]
|
||||
[InlineData(Ownership.Unresolvable)]
|
||||
public async Task Expiry_CommitsAndAuditsResolvedWorkOrder(Ownership ownership)
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(connection).Options;
|
||||
|
||||
int upliftId;
|
||||
int? expectedWorkOrderId;
|
||||
await using (var seed = new SqliteUpliftDecisionTestDbContext(options))
|
||||
{
|
||||
await seed.Database.EnsureCreatedAsync();
|
||||
(upliftId, expectedWorkOrderId) = await SeedAsync(seed, ownership, expiresAt: DateTime.UtcNow.AddHours(-1));
|
||||
}
|
||||
|
||||
await using (var context = new SqliteUpliftDecisionTestDbContext(options))
|
||||
{
|
||||
var lifecycle = new UpliftLifecycleService(
|
||||
new UpliftDataService(context),
|
||||
new DispatchDataService(context),
|
||||
null!,
|
||||
new NoEmailSender(),
|
||||
Options.Create(new FrontendOptions()),
|
||||
Options.Create(new ApprovalsOptions()),
|
||||
TimeProvider.System,
|
||||
NullLogger<UpliftLifecycleService>.Instance);
|
||||
|
||||
Assert.Equal(1, await lifecycle.ExpireDueAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
await using var verify = new SqliteUpliftDecisionTestDbContext(options);
|
||||
var saved = await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == upliftId);
|
||||
Assert.Equal(UpliftStatus.Expired, saved.Status);
|
||||
|
||||
var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync();
|
||||
Assert.DoesNotContain(audits, a => a.WorkOrderId == 0);
|
||||
if (expectedWorkOrderId is int workOrderId)
|
||||
Assert.Equal(workOrderId, Assert.Single(audits).WorkOrderId);
|
||||
else
|
||||
Assert.Empty(audits);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Ownership.Linked)]
|
||||
[InlineData(Ownership.Unresolvable)]
|
||||
public async Task Escalation_CommitsAndAuditsResolvedWorkOrder(Ownership ownership)
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(connection).Options;
|
||||
|
||||
int upliftId;
|
||||
int? expectedWorkOrderId;
|
||||
await using (var seed = new SqliteUpliftDecisionTestDbContext(options))
|
||||
{
|
||||
await seed.Database.EnsureCreatedAsync();
|
||||
(upliftId, expectedWorkOrderId) = await SeedAsync(
|
||||
seed,
|
||||
ownership,
|
||||
expiresAt: null,
|
||||
initialNotificationSentAt: DateTime.UtcNow.AddDays(-3));
|
||||
}
|
||||
|
||||
var emailSender = new NoEmailSender();
|
||||
await using (var context = new SqliteUpliftDecisionTestDbContext(options))
|
||||
{
|
||||
var lifecycle = new UpliftLifecycleService(
|
||||
new UpliftDataService(context),
|
||||
new DispatchDataService(context),
|
||||
null!,
|
||||
emailSender,
|
||||
Options.Create(new FrontendOptions()),
|
||||
Options.Create(new ApprovalsOptions { EscalationRecipients = new[] { "escalate@test.local" } }),
|
||||
TimeProvider.System,
|
||||
NullLogger<UpliftLifecycleService>.Instance);
|
||||
|
||||
Assert.Equal(1, await lifecycle.EscalateDueAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
await using var verify = new SqliteUpliftDecisionTestDbContext(options);
|
||||
var saved = await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == upliftId);
|
||||
Assert.NotNull(saved.EscalatedAt);
|
||||
Assert.Equal(1, emailSender.Calls);
|
||||
|
||||
var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync();
|
||||
Assert.DoesNotContain(audits, a => a.WorkOrderId == 0);
|
||||
if (expectedWorkOrderId is int workOrderId)
|
||||
Assert.Equal(workOrderId, Assert.Single(audits).WorkOrderId);
|
||||
else
|
||||
Assert.Empty(audits);
|
||||
}
|
||||
|
||||
private static async Task<(int UpliftId, int? ExpectedWorkOrderId)> SeedAsync(
|
||||
ApplicationDbContext context,
|
||||
Ownership ownership,
|
||||
DateTime? expiresAt,
|
||||
DateTime? initialNotificationSentAt = null)
|
||||
{
|
||||
context.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = AdminId,
|
||||
UserName = AdminId,
|
||||
NormalizedUserName = AdminId.ToUpperInvariant(),
|
||||
Email = "admin@test.local",
|
||||
NormalizedEmail = "ADMIN@TEST.LOCAL"
|
||||
});
|
||||
var vendor = new Vendor { CompanyName = "Vinewood LLC", IsActive = true };
|
||||
// A second work order keeps ids distinct from the first, so an audit landing on
|
||||
// the wrong work order cannot pass by coincidence.
|
||||
var unrelated = new WorkOrder { InternalWONumber = "WO-OTHER", WorkerOrderTitle = "Other" };
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-TARGET",
|
||||
WorkerOrderTitle = "Repair",
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
};
|
||||
context.AddRange(vendor, unrelated, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
WorkOrderId = ownership == Ownership.Owned ? workOrder.Id : null,
|
||||
DispatchNumber = "DIS-UPLIFT",
|
||||
Status = "Scheduled",
|
||||
NTEAmount = 1_000m
|
||||
};
|
||||
context.Dispatches.Add(dispatch);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
if (ownership == Ownership.Linked)
|
||||
{
|
||||
context.DispatchWorkOrders.Add(new DispatchWorkOrder
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
WorkOrderId = workOrder.Id
|
||||
});
|
||||
}
|
||||
|
||||
var request = new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1_000m,
|
||||
RequestedNTE = 5_000m,
|
||||
VendorReason = "Scope grew",
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = 2,
|
||||
CreatedDate = DateTime.UtcNow.AddDays(-4),
|
||||
ExpiresAt = expiresAt,
|
||||
InitialNotificationSentAt = initialNotificationSentAt,
|
||||
NotificationStatus = initialNotificationSentAt == null ? "Pending" : "Sent"
|
||||
};
|
||||
context.DispatchUpliftRequests.Add(request);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return (request.Id, ownership == Ownership.Unresolvable ? null : workOrder.Id);
|
||||
}
|
||||
|
||||
private sealed class NoDocumentStorage : IVendorDocumentStoragePort
|
||||
{
|
||||
public Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Stream OpenRead(int vendorId, int dispatchId, string storedFileName)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public void Delete(int vendorId, int dispatchId, string storedFileName)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
// Records sends without delivering anything.
|
||||
private sealed class NoEmailSender : IEmailSender
|
||||
{
|
||||
public int Calls;
|
||||
|
||||
public Task<bool> SendEmailAsync(string emailTo, string subject, string body)
|
||||
{
|
||||
Calls++;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SqliteUpliftDecisionTestDbContext : ApplicationDbContext
|
||||
{
|
||||
public SqliteUpliftDecisionTestDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
|
||||
// SQL Server filtered index syntax is invalid on SQLite.
|
||||
foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes()))
|
||||
{
|
||||
if (index.GetFilter() != null)
|
||||
index.SetFilter(null);
|
||||
}
|
||||
|
||||
// SQLite has no rowversion type; treat as plain nullable blobs.
|
||||
foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) })
|
||||
{
|
||||
var property = builder.Entity(entityType).Property("RowVersion").Metadata;
|
||||
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
|
||||
property.IsConcurrencyToken = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
|
|
@ -203,6 +204,8 @@ public class WorkOrderBoardCancelServiceTests
|
|||
new WorkOrderDetailDataService(context),
|
||||
WorkOrderAccountTestHelpers.Resolver(context),
|
||||
new UserDataService(context),
|
||||
new TeamPermissionOverrideDataService(context),
|
||||
new TeamPermissionPolicy(),
|
||||
TimeProvider.System,
|
||||
Options.Create(new ApprovalsOptions()));
|
||||
var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts, new PassThroughUpliftData());
|
||||
|
|
@ -264,6 +267,8 @@ public class WorkOrderBoardCancelServiceTests
|
|||
new WorkOrderDetailDataService(context),
|
||||
WorkOrderAccountTestHelpers.Resolver(context),
|
||||
new UserDataService(context),
|
||||
new TeamPermissionOverrideDataService(context),
|
||||
new TeamPermissionPolicy(),
|
||||
TimeProvider.System,
|
||||
Options.Create(new ApprovalsOptions()));
|
||||
var cancel = new WorkOrderBoardCancelService(
|
||||
|
|
@ -300,6 +305,13 @@ public class WorkOrderBoardCancelServiceTests
|
|||
FirstName = "Alex",
|
||||
LastName = "Dispatcher",
|
||||
});
|
||||
var adminRole = new IdentityRole("Admin");
|
||||
seed.Roles.Add(adminRole);
|
||||
seed.UserRoles.Add(new IdentityUserRole<string>
|
||||
{
|
||||
UserId = "dispatcher-1",
|
||||
RoleId = adminRole.Id,
|
||||
});
|
||||
seed.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
|
||||
seed.Dispatches.Add(new Dispatch
|
||||
{
|
||||
|
|
@ -332,6 +344,8 @@ public class WorkOrderBoardCancelServiceTests
|
|||
new WorkOrderDetailDataService(createContext),
|
||||
WorkOrderAccountTestHelpers.Resolver(createContext),
|
||||
new UserDataService(createContext),
|
||||
new TeamPermissionOverrideDataService(createContext),
|
||||
new TeamPermissionPolicy(),
|
||||
TimeProvider.System,
|
||||
Options.Create(new ApprovalsOptions()));
|
||||
var boardData = new WorkOrderBoardDataService(cancelContext);
|
||||
|
|
@ -345,6 +359,8 @@ public class WorkOrderBoardCancelServiceTests
|
|||
new WorkOrderDetailDataService(cancelContext),
|
||||
WorkOrderAccountTestHelpers.Resolver(cancelContext),
|
||||
new UserDataService(cancelContext),
|
||||
new TeamPermissionOverrideDataService(cancelContext),
|
||||
new TeamPermissionPolicy(),
|
||||
TimeProvider.System,
|
||||
Options.Create(new ApprovalsOptions()));
|
||||
var cancel = new WorkOrderBoardCancelService(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
|
|
@ -31,6 +32,11 @@ public sealed class WorkOrderUpliftDispatchOwnershipTests
|
|||
var context = new ApplicationDbContext(options);
|
||||
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
|
||||
context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Acme HVAC" });
|
||||
// SH-327: uplift creation checks the actor's RequestUplifts permission.
|
||||
var adminRole = new IdentityRole("Admin");
|
||||
context.Roles.Add(adminRole);
|
||||
context.Users.Add(new ApplicationUser { Id = "actor-1", UserName = "actor-1" });
|
||||
context.UserRoles.Add(new IdentityUserRole<string> { UserId = "actor-1", RoleId = adminRole.Id });
|
||||
context.SaveChanges();
|
||||
return context;
|
||||
}
|
||||
|
|
@ -67,6 +73,8 @@ public sealed class WorkOrderUpliftDispatchOwnershipTests
|
|||
new WorkOrderDetailDataService(context),
|
||||
WorkOrderAccountTestHelpers.Resolver(context),
|
||||
new UserDataService(context),
|
||||
new TeamPermissionOverrideDataService(context),
|
||||
new TeamPermissionPolicy(),
|
||||
TimeProvider.System,
|
||||
Options.Create(new ApprovalsOptions
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
|
@ -37,6 +39,8 @@ public sealed class WorkOrderUpliftServiceTests
|
|||
new WorkOrderDetailDataService(context),
|
||||
WorkOrderAccountTestHelpers.Resolver(context),
|
||||
new UserDataService(context),
|
||||
new TeamPermissionOverrideDataService(context),
|
||||
new TeamPermissionPolicy(),
|
||||
TimeProvider.System,
|
||||
Options.Create(NewOptions()));
|
||||
}
|
||||
|
|
@ -47,6 +51,7 @@ public sealed class WorkOrderUpliftServiceTests
|
|||
private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync(
|
||||
ApplicationDbContext context,
|
||||
WorkOrderType type = WorkOrderType.PM,
|
||||
string role = "Admin",
|
||||
int? primaryDispatchId = 10)
|
||||
{
|
||||
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
|
||||
|
|
@ -57,6 +62,13 @@ public sealed class WorkOrderUpliftServiceTests
|
|||
FirstName = "Alex",
|
||||
LastName = "Dispatcher",
|
||||
});
|
||||
var identityRole = new IdentityRole(role);
|
||||
context.Roles.Add(identityRole);
|
||||
context.UserRoles.Add(new IdentityUserRole<string>
|
||||
{
|
||||
UserId = "dispatcher-1",
|
||||
RoleId = identityRole.Id,
|
||||
});
|
||||
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
|
||||
context.Dispatches.Add(new Dispatch
|
||||
{
|
||||
|
|
@ -124,6 +136,22 @@ public sealed class WorkOrderUpliftServiceTests
|
|||
Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_CurrentDatabaseAdmin_RemainsUnrestricted()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var (workOrder, _) = await SeedWorkOrderAsync(context);
|
||||
var service = NewService(context);
|
||||
|
||||
var created = await service.CreateAsync(
|
||||
workOrder.Id,
|
||||
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
|
||||
WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, "Scheduler"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithoutPrimaryDispatch_UsesWorkOrderDispatch()
|
||||
{
|
||||
|
|
@ -174,6 +202,109 @@ public sealed class WorkOrderUpliftServiceTests
|
|||
Assert.Equal(workOrder.Id, audit.WorkOrderId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_StaleAdminClaim_DeniesWhenDatabaseRoleIsNotAdmin()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var (workOrder, dispatch) = await SeedWorkOrderAsync(context, role: "Scheduler");
|
||||
var service = NewService(context);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(() => service.CreateAsync(
|
||||
workOrder.Id,
|
||||
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
|
||||
Dispatcher(),
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Equal(UpliftForbiddenException.RequestUpliftsDeniedMessage, exception.Message);
|
||||
Assert.Empty(context.DispatchUpliftRequests);
|
||||
Assert.Equal(1000m, dispatch.NTEAmount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_MissingDatabaseUser_DeniesEvenWithAdminClaim()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
|
||||
context.UserRoles.RemoveRange(context.UserRoles.Where(userRole => userRole.UserId == "dispatcher-1"));
|
||||
context.Users.Remove(context.Users.Single(user => user.Id == "dispatcher-1"));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(() => service.CreateAsync(
|
||||
workOrder.Id,
|
||||
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
|
||||
Dispatcher("dispatcher-1"),
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Equal(UpliftForbiddenException.RequestUpliftsDeniedMessage, exception.Message);
|
||||
Assert.Empty(context.DispatchUpliftRequests);
|
||||
Assert.Equal(1000m, dispatch.NTEAmount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Dispatcher", true)]
|
||||
[InlineData("Scheduler", false)]
|
||||
public async Task CreateAsync_UsesRoleDefaultForRequestPermission(string role, bool allowed)
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var (workOrder, dispatch) = await SeedWorkOrderAsync(context, role: role);
|
||||
var service = NewService(context);
|
||||
|
||||
var act = () => service.CreateAsync(
|
||||
workOrder.Id,
|
||||
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
|
||||
WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, role),
|
||||
CancellationToken.None);
|
||||
|
||||
if (allowed)
|
||||
{
|
||||
Assert.NotNull(await act());
|
||||
return;
|
||||
}
|
||||
|
||||
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(act);
|
||||
Assert.Equal("Your role can't request uplifts on this work order.", exception.Message);
|
||||
Assert.Empty(context.DispatchUpliftRequests);
|
||||
Assert.Equal(1000m, dispatch.NTEAmount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Scheduler", UserPermissionState.Allow, true)]
|
||||
[InlineData("Dispatcher", UserPermissionState.Deny, false)]
|
||||
public async Task CreateAsync_AppliesExplicitRequestPermissionOverride(
|
||||
string role,
|
||||
UserPermissionState overrideState,
|
||||
bool allowed)
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var (workOrder, dispatch) = await SeedWorkOrderAsync(context, role: role);
|
||||
context.UserPermissionOverrides.Add(new UserPermissionOverride
|
||||
{
|
||||
UserId = "dispatcher-1",
|
||||
PermissionKey = TeamPermissionKeys.RequestUplifts,
|
||||
State = overrideState,
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var act = () => service.CreateAsync(
|
||||
workOrder.Id,
|
||||
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
|
||||
WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, role),
|
||||
CancellationToken.None);
|
||||
|
||||
if (allowed)
|
||||
{
|
||||
Assert.NotNull(await act());
|
||||
return;
|
||||
}
|
||||
|
||||
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(act);
|
||||
Assert.Equal("Your role can't request uplifts on this work order.", exception.Message);
|
||||
Assert.Empty(context.DispatchUpliftRequests);
|
||||
Assert.Equal(1000m, dispatch.NTEAmount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_PmAmountAboveCap_CreatesPendingRequest()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue