mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-06 10:02:09 +00:00
Merge branch 'main' into feat/ab/sh-392-dashboard-unassigned
This commit is contained in:
commit
c49a98db18
65 changed files with 8930 additions and 255 deletions
6
.platform/nginx/conf.d/01_upload_body_size.conf
Normal file
6
.platform/nginx/conf.d/01_upload_body_size.conf
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# The Elastic Beanstalk nginx proxy defaults client_max_body_size to 1m,
|
||||||
|
# which returned 413 for every media upload over ~1 MB before the request reached
|
||||||
|
# the API. The cap sits above the API's own request limit
|
||||||
|
# (WorkOrderMediaContract.MaxUploadRequestBytes = 110 MB) so oversize uploads get
|
||||||
|
# the API's generic per-kind message instead of an nginx error page.
|
||||||
|
client_max_body_size 120M;
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -278,7 +278,8 @@ public sealed class UpliftQueueReadTests
|
||||||
item.RequestedByName.Should().Be("Gateway");
|
item.RequestedByName.Should().Be("Gateway");
|
||||||
item.RequestedAt.Should().Be(new DateTime(2026, 3, 2));
|
item.RequestedAt.Should().Be(new DateTime(2026, 3, 2));
|
||||||
item.VendorReason.Should().Be("Scope grew");
|
item.VendorReason.Should().Be("Scope grew");
|
||||||
item.Delta.Should().Be(300m);
|
// A work-order request (createdby set) stores the increase itself in RequestedNTE.
|
||||||
|
item.Delta.Should().Be(400m);
|
||||||
item.Status.Should().Be("Pending");
|
item.Status.Should().Be("Pending");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -558,7 +559,7 @@ public sealed class UpliftQueueReadTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task List_PendingExposureTotal_SumsGrantedAmountsAcrossAllPendingRequests()
|
public async Task List_PendingExposureTotal_SumsEachPendingRequestsIncreaseAcrossTheQueue()
|
||||||
{
|
{
|
||||||
using var context = NewContext();
|
using var context = NewContext();
|
||||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
|
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
|
||||||
|
|
@ -567,10 +568,10 @@ public sealed class UpliftQueueReadTests
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
context.DispatchUpliftRequests.AddRange(
|
context.DispatchUpliftRequests.AddRange(
|
||||||
// Vendor-portal rows (createdby null) store the requested new NTE total,
|
// Vendor-portal rows (createdby null) store the requested new NTE total,
|
||||||
// so the pending exposure is the delta: 400 - 100 = 300 and 600 - 250 = 350.
|
// so their increase is 400 - 100 = 300 and 600 - 250 = 350.
|
||||||
Request(dispatch, "Pending", new DateTime(2026, 3, 1), requested: 400m, currentNte: 100m),
|
Request(dispatch, "Pending", new DateTime(2026, 3, 1), requested: 400m, currentNte: 100m),
|
||||||
Request(dispatch, "Pending", new DateTime(2026, 3, 2), requested: 600m, currentNte: 250m),
|
Request(dispatch, "Pending", new DateTime(2026, 3, 2), requested: 600m, currentNte: 250m),
|
||||||
// Work-order-path rows (createdby set) store the requested increment: 90.
|
// Work-order rows (createdby set) store the increase itself: 90.
|
||||||
Request(dispatch, "Pending", new DateTime(2026, 3, 3), requested: 90m, currentNte: 600m, createdBy: "user-7"),
|
Request(dispatch, "Pending", new DateTime(2026, 3, 3), requested: 90m, currentNte: 600m, createdBy: "user-7"),
|
||||||
// Non-pending rows never add pending exposure.
|
// Non-pending rows never add pending exposure.
|
||||||
Request(dispatch, "Approved", new DateTime(2026, 2, 1), decided: new DateTime(2026, 2, 2), requested: 500m, currentNte: 100m));
|
Request(dispatch, "Approved", new DateTime(2026, 2, 1), decided: new DateTime(2026, 2, 2), requested: 500m, currentNte: 100m));
|
||||||
|
|
@ -584,6 +585,38 @@ public sealed class UpliftQueueReadTests
|
||||||
result.PendingExposureTotal.Should().Be(740m);
|
result.PendingExposureTotal.Should().Be(740m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task List_PendingExposureTotal_EqualsSumOfPendingRowAmounts_ForBothCreationPaths()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
|
||||||
|
var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "HVAC");
|
||||||
|
var (vendorC, workOrderC) = await SeedWorkOrderAsync(context, "WO-C", "SITE-C", "HVAC");
|
||||||
|
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
|
||||||
|
var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B");
|
||||||
|
var deletedDispatch = await SeedDispatchAsync(context, vendorC, workOrderC, "DIS-C");
|
||||||
|
deletedDispatch.IsDeleted = true;
|
||||||
|
context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" });
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
context.DispatchUpliftRequests.AddRange(
|
||||||
|
// Raised from the work order, which already carries a 3,000 NTE: RequestedNTE
|
||||||
|
// is the 5,000,000,000 increase itself.
|
||||||
|
Request(dispatchA, "Pending", new DateTime(2026, 3, 1), requested: 5_000_000_000m, currentNte: 3_000m, createdBy: "user-7"),
|
||||||
|
// Vendor-portal request for a 3,333 total on a 3,000 NTE: a 333 increase.
|
||||||
|
Request(dispatchB, "Pending", new DateTime(2026, 3, 2), requested: 3_333m, currentNte: 3_000m),
|
||||||
|
// A pending request on a deleted dispatch is not a queue row, so it adds nothing.
|
||||||
|
Request(deletedDispatch, "Pending", new DateTime(2026, 3, 3), requested: 700m, currentNte: 100m));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var service = NewService(context);
|
||||||
|
|
||||||
|
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Items.Should().HaveCount(2);
|
||||||
|
result.Items.Select(i => i.Delta).Should().BeEquivalentTo(new[] { 5_000_000_000m, 333m });
|
||||||
|
result.PendingExposureTotal.Should().Be(5_000_000_333m);
|
||||||
|
result.PendingExposureTotal.Should().Be(result.Items.Sum(i => i.Delta));
|
||||||
|
}
|
||||||
|
|
||||||
// --- Approved-on-WO exposure totals ---
|
// --- Approved-on-WO exposure totals ---
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -388,6 +419,44 @@ public sealed class UpliftWorkflowTests
|
||||||
context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_revised");
|
context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_revised");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Revise_WorkOrderRequest_IsRefusedAsNotFound_AndLeavesTheRowUnchanged()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 600m);
|
||||||
|
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
|
||||||
|
// Raised from the work order by an internal user and sent back for changes:
|
||||||
|
// RequestedNTE holds the 90 increase, not a total.
|
||||||
|
var workOrderRequest = new DispatchUpliftRequest
|
||||||
|
{
|
||||||
|
DispatchId = dispatch.Id,
|
||||||
|
CurrentNTE = 600m,
|
||||||
|
RequestedNTE = 90m,
|
||||||
|
Status = UpliftStatus.ChangesRequested,
|
||||||
|
RequiredTier = 1,
|
||||||
|
VendorReason = "Extra parts",
|
||||||
|
RequestedByVendorName = "Alex Dispatcher",
|
||||||
|
NotificationStatus = "Sent",
|
||||||
|
createdby = "dispatcher-1",
|
||||||
|
CreatedDate = new DateTime(2026, 3, 1),
|
||||||
|
};
|
||||||
|
context.DispatchUpliftRequests.Add(workOrderRequest);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
|
||||||
|
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
||||||
|
|
||||||
|
var act = () => service.ReviseUpliftAsync(session!, dispatch.Id, workOrderRequest.Id, 900m, "vendor total", 1, CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||||
|
var after = context.DispatchUpliftRequests.AsNoTracking().Single(u => u.Id == workOrderRequest.Id);
|
||||||
|
after.Status.Should().Be(UpliftStatus.ChangesRequested);
|
||||||
|
after.RequestedNTE.Should().Be(90m);
|
||||||
|
after.CurrentNTE.Should().Be(600m);
|
||||||
|
after.VendorReason.Should().Be("Extra parts");
|
||||||
|
after.createdby.Should().Be("dispatcher-1");
|
||||||
|
context.WorkOrderAuditLogs.Should().NotContain(a => a.Action == "uplift_revised");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Withdraw_Pending_TransitionsToWithdrawn_SetsDecidedAt()
|
public async Task Withdraw_Pending_TransitionsToWithdrawn_SetsDecidedAt()
|
||||||
{
|
{
|
||||||
|
|
@ -427,6 +496,92 @@ public sealed class UpliftWorkflowTests
|
||||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Raised from the work order by an internal user: createdby is set and RequestedNTE
|
||||||
|
// holds the increase. Vendor sessions have no identity user, so they never set it.
|
||||||
|
private static DispatchUpliftRequest WorkOrderRequest(int dispatchId, string status) => new()
|
||||||
|
{
|
||||||
|
DispatchId = dispatchId,
|
||||||
|
CurrentNTE = 600m,
|
||||||
|
RequestedNTE = 90m,
|
||||||
|
Status = status,
|
||||||
|
RequiredTier = 1,
|
||||||
|
VendorReason = "Extra parts",
|
||||||
|
RequestedByVendorName = "Alex Dispatcher",
|
||||||
|
NotificationStatus = "Sent",
|
||||||
|
createdby = "dispatcher-1",
|
||||||
|
CreatedDate = new DateTime(2026, 3, 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Pending", "withdraw")]
|
||||||
|
[InlineData("ChangesRequested", "withdraw")]
|
||||||
|
[InlineData("Pending", "cancel")]
|
||||||
|
[InlineData("ChangesRequested", "cancel")]
|
||||||
|
public async Task Withdraw_WorkOrderRequest_IsRefusedAsNotFound_AndLeavesTheRowUnchanged(string status, string route)
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var (_, _, dispatch) = await SeedAsync(context, nte: 600m);
|
||||||
|
var workOrderRequest = WorkOrderRequest(dispatch.Id, status);
|
||||||
|
context.DispatchUpliftRequests.Add(workOrderRequest);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
|
||||||
|
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
||||||
|
|
||||||
|
Func<Task> act = route == "cancel"
|
||||||
|
? () => service.CancelUpliftRequestAsync(session!, dispatch.Id, workOrderRequest.Id, CancellationToken.None)
|
||||||
|
: () => service.WithdrawUpliftAsync(session!, dispatch.Id, workOrderRequest.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||||
|
var after = context.DispatchUpliftRequests.AsNoTracking().Single(u => u.Id == workOrderRequest.Id);
|
||||||
|
after.Status.Should().Be(status);
|
||||||
|
after.DecidedAt.Should().BeNull();
|
||||||
|
after.RequestedNTE.Should().Be(90m);
|
||||||
|
after.createdby.Should().Be("dispatcher-1");
|
||||||
|
context.WorkOrderAuditLogs.Should().NotContain(a => a.Action == "uplift_withdraw" || a.Action == "uplift_cancel");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancel_VendorRaisedChangesRequested_StillWithdraws()
|
||||||
|
{
|
||||||
|
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 service = NewPortalService(context, new FakeEmailSender(deliver: true));
|
||||||
|
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
||||||
|
var created = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None);
|
||||||
|
context.DispatchUpliftRequests.Single().Status = UpliftStatus.ChangesRequested;
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await service.CancelUpliftRequestAsync(session!, dispatch.Id, created.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Status.Should().Be(UpliftStatus.Withdrawn);
|
||||||
|
context.DispatchUpliftRequests.AsNoTracking().Single().Status.Should().Be(UpliftStatus.Withdrawn);
|
||||||
|
context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_cancel" && a.OldValue == UpliftStatus.ChangesRequested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DispatchDetail_ReportsRaisedByVendor_PerCreationPath()
|
||||||
|
{
|
||||||
|
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 service = NewPortalService(context, new FakeEmailSender(deliver: true));
|
||||||
|
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
||||||
|
var vendorRaised = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None);
|
||||||
|
await service.WithdrawUpliftAsync(session!, dispatch.Id, vendorRaised.Id, CancellationToken.None);
|
||||||
|
var workOrderRequest = WorkOrderRequest(dispatch.Id, UpliftStatus.Pending);
|
||||||
|
context.DispatchUpliftRequests.Add(workOrderRequest);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
detail!.UpliftRequests.Should().HaveCount(2);
|
||||||
|
detail.UpliftRequests.Single(u => u.Id == vendorRaised.Id).RaisedByVendor.Should().BeTrue();
|
||||||
|
detail.UpliftRequests.Single(u => u.Id == workOrderRequest.Id).RaisedByVendor.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RequestChanges_Pending_TransitionsToChangesRequested_AuditsOldValue()
|
public async Task RequestChanges_Pending_TransitionsToChangesRequested_AuditsOldValue()
|
||||||
{
|
{
|
||||||
|
|
@ -650,6 +805,8 @@ public sealed class UpliftWorkflowTests
|
||||||
var upliftData = new Mock<IUpliftDataService>();
|
var upliftData = new Mock<IUpliftDataService>();
|
||||||
upliftData.Setup(u => u.GetDueForExpiryAsync(It.IsAny<DateTime>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
upliftData.Setup(u => u.GetDueForExpiryAsync(It.IsAny<DateTime>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<DispatchUpliftRequest> { orphanReq, validReq });
|
.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>()))
|
upliftData.Setup(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||||
.Returns(Task.CompletedTask);
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,11 @@ public sealed class VendorPortalDocumentTests : IDisposable
|
||||||
var upliftData = new Mock<IUpliftDataService>();
|
var upliftData = new Mock<IUpliftDataService>();
|
||||||
upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<PortalUpliftData>());
|
.ReturnsAsync(new List<PortalUpliftData>());
|
||||||
|
upliftData.Setup(u => u.ExecuteWorkOrderMutationAsync(
|
||||||
|
It.IsAny<int>(),
|
||||||
|
It.IsAny<Func<CancellationToken, Task<VendorCompletionDocument>>>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.Returns((int _, Func<CancellationToken, Task<VendorCompletionDocument>> work, CancellationToken ct) => work(ct));
|
||||||
var commentData = new Mock<ICommentDataService>();
|
var commentData = new Mock<ICommentDataService>();
|
||||||
commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<PortalCommentData>());
|
.ReturnsAsync(new List<PortalCommentData>());
|
||||||
|
|
@ -363,6 +368,278 @@ public sealed class VendorPortalDocumentTests : IDisposable
|
||||||
context.VendorCompletionDocuments.Should().HaveCount(1);
|
context.VendorCompletionDocuments.Should().HaveCount(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UploadCompletionDocument_AdvertisesContractRequestLimit()
|
||||||
|
{
|
||||||
|
var attribute = Assert.Single(
|
||||||
|
typeof(VendorPortalController)
|
||||||
|
.GetMethod(nameof(VendorPortalController.UploadCompletionDocument))!
|
||||||
|
.CustomAttributes,
|
||||||
|
candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute));
|
||||||
|
var bytes = Assert.Single(attribute.ConstructorArguments);
|
||||||
|
|
||||||
|
bytes.Value.Should().Be(110_000_000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] MediaBytes(int size, params byte[] header)
|
||||||
|
{
|
||||||
|
var bytes = new byte[size];
|
||||||
|
header.AsSpan().CopyTo(bytes);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] FtypVideoBytes(int size) => MediaBytes(
|
||||||
|
size, 0x00, 0x00, 0x00, 0x20, (byte)'f', (byte)'t', (byte)'y', (byte)'p',
|
||||||
|
(byte)'i', (byte)'s', (byte)'o', (byte)'m');
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadCompletionDocument_AcceptsSixtyMegabyteVideo()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var (_, dispatch) = await SeedDispatch(context);
|
||||||
|
|
||||||
|
var result = await NewController(context).UploadCompletionDocument(
|
||||||
|
dispatch.Id,
|
||||||
|
FormFile(FtypVideoBytes(60_000_000), "site-clip.mp4", "video/mp4"));
|
||||||
|
|
||||||
|
result.Should().BeOfType<OkObjectResult>();
|
||||||
|
var document = await context.VendorCompletionDocuments.SingleAsync();
|
||||||
|
document.ContentType.Should().Be("video/mp4");
|
||||||
|
document.SizeBytes.Should().Be(60_000_000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadCompletionDocument_AcceptsMovWithEmptyContentType()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var (_, dispatch) = await SeedDispatch(context);
|
||||||
|
|
||||||
|
var result = await NewController(context).UploadCompletionDocument(
|
||||||
|
dispatch.Id,
|
||||||
|
FormFile(FtypVideoBytes(2_048), "site-clip.MOV", ""));
|
||||||
|
|
||||||
|
result.Should().BeOfType<OkObjectResult>();
|
||||||
|
(await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("video/quicktime");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadCompletionDocument_AcceptsIosTranscodedJpegLabelledHeic()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var (_, dispatch) = await SeedDispatch(context);
|
||||||
|
|
||||||
|
var result = await NewController(context).UploadCompletionDocument(
|
||||||
|
dispatch.Id,
|
||||||
|
FormFile(MediaBytes(4_096, 0xFF, 0xD8, 0xFF, 0xE0), "IMG_2044.jpg", "image/heic"));
|
||||||
|
|
||||||
|
result.Should().BeOfType<OkObjectResult>();
|
||||||
|
(await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("image/jpeg");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// Genuine PDF/JPEG bytes and declared type, but a video file name: the size class must
|
||||||
|
// follow the validated type, not the name, or the document/photo caps are bypassed.
|
||||||
|
[InlineData("report.mp4", "application/pdf", 12_000_000, new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D })]
|
||||||
|
[InlineData("site.mov", "image/jpeg", 11_000_000, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 })]
|
||||||
|
public async Task UploadCompletionDocument_VideoExtensionDoesNotWidenSizeCap(
|
||||||
|
string fileName,
|
||||||
|
string contentType,
|
||||||
|
int size,
|
||||||
|
byte[] header)
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var (_, dispatch) = await SeedDispatch(context);
|
||||||
|
|
||||||
|
var result = await NewController(context).UploadCompletionDocument(
|
||||||
|
dispatch.Id,
|
||||||
|
FormFile(MediaBytes(size, header), fileName, contentType));
|
||||||
|
|
||||||
|
result.Should().BeOfType<BadRequestObjectResult>();
|
||||||
|
context.VendorCompletionDocuments.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ftyp + mdat + moov/mvhd (moov last, as phones write it).</summary>
|
||||||
|
private static byte[] PhoneVideoBytes(uint durationSeconds)
|
||||||
|
{
|
||||||
|
static byte[] Box(string type, byte[] body)
|
||||||
|
{
|
||||||
|
var box = new byte[8 + body.Length];
|
||||||
|
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length);
|
||||||
|
System.Text.Encoding.ASCII.GetBytes(type).CopyTo(box, 4);
|
||||||
|
body.CopyTo(box, 8);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mvhd = new byte[20];
|
||||||
|
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(12), 600);
|
||||||
|
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(16), durationSeconds * 600);
|
||||||
|
return Box("ftyp", System.Text.Encoding.ASCII.GetBytes("qt \0\0\0\0"))
|
||||||
|
.Concat(Box("mdat", new byte[4096]))
|
||||||
|
.Concat(Box("moov", Box("mvhd", mvhd)))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(95u, false)]
|
||||||
|
[InlineData(89u, true)]
|
||||||
|
public async Task UploadCompletionDocument_EnforcesNinetySecondVideoLimit(uint seconds, bool accepted)
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var (_, dispatch) = await SeedDispatch(context);
|
||||||
|
|
||||||
|
var result = await NewController(context).UploadCompletionDocument(
|
||||||
|
dispatch.Id,
|
||||||
|
FormFile(PhoneVideoBytes(seconds), "IMG_0042.MOV", "video/quicktime"));
|
||||||
|
|
||||||
|
if (accepted)
|
||||||
|
{
|
||||||
|
result.Should().BeOfType<OkObjectResult>();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result.Should().BeOfType<BadRequestObjectResult>();
|
||||||
|
context.VendorCompletionDocuments.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadCompletionDocument_RejectsFourthVideoAcrossDispatcherAndVendorUploads()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var (_, dispatch) = await SeedDispatch(context);
|
||||||
|
foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV", "clip.mp4" })
|
||||||
|
{
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = dispatch.WorkOrderId!.Value,
|
||||||
|
Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await NewController(context).UploadCompletionDocument(
|
||||||
|
dispatch.Id,
|
||||||
|
FormFile(FtypVideoBytes(2_048), "site-clip.MOV", "video/quicktime"));
|
||||||
|
|
||||||
|
result.Should().BeOfType<BadRequestObjectResult>();
|
||||||
|
context.VendorCompletionDocuments.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadCompletionDocument_NewVersionDoesNotCountTheVideoItReplaces()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var (vendor, dispatch) = await SeedDispatch(context);
|
||||||
|
foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV" })
|
||||||
|
{
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = dispatch.WorkOrderId!.Value,
|
||||||
|
Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
|
||||||
|
{
|
||||||
|
VendorId = vendor.Id,
|
||||||
|
DispatchId = dispatch.Id,
|
||||||
|
WorkOrderId = dispatch.WorkOrderId!.Value,
|
||||||
|
ContentType = "video/mp4",
|
||||||
|
StoredFileName = "v1.mp4",
|
||||||
|
Version = 1,
|
||||||
|
Purpose = "Completion"
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await NewController(context).UploadCompletionDocument(
|
||||||
|
dispatch.Id,
|
||||||
|
FormFile(FtypVideoBytes(2_048), "site-clip-v2.mp4", "video/mp4"));
|
||||||
|
|
||||||
|
result.Should().BeOfType<OkObjectResult>();
|
||||||
|
context.VendorCompletionDocuments.Should().HaveCount(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDispatchDetail_ReportsWorkOrderMediaCountsForThePortalPreCheck()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var (vendor, dispatch) = await SeedDispatch(context);
|
||||||
|
foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV", "IMG_0003.jpg" })
|
||||||
|
{
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = dispatch.WorkOrderId!.Value,
|
||||||
|
Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
|
||||||
|
{
|
||||||
|
VendorId = vendor.Id,
|
||||||
|
DispatchId = dispatch.Id,
|
||||||
|
WorkOrderId = dispatch.WorkOrderId!.Value,
|
||||||
|
ContentType = "video/mp4",
|
||||||
|
StoredFileName = "v1.mp4",
|
||||||
|
Version = 1,
|
||||||
|
Purpose = "Completion"
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var service = NewService(context);
|
||||||
|
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
||||||
|
var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
detail!.MediaCounts.Should().BeEquivalentTo(new SeaHaven.Services.DTOs.PortalMediaCountsDTO
|
||||||
|
{
|
||||||
|
MaxPhotos = 10,
|
||||||
|
MaxVideos = 3,
|
||||||
|
Photos = 1,
|
||||||
|
Videos = 3,
|
||||||
|
CompletionPhotos = 1,
|
||||||
|
CompletionVideos = 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var (_, dispatch) = await SeedDispatch(context);
|
||||||
|
|
||||||
|
var result = await NewController(context).UploadCompletionDocument(
|
||||||
|
dispatch.Id,
|
||||||
|
FormFile(FtypVideoBytes(100_000_001), "site-clip.mp4", "video/mp4"));
|
||||||
|
|
||||||
|
result.Should().BeOfType<BadRequestObjectResult>();
|
||||||
|
context.VendorCompletionDocuments.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadCompletionDocument_RejectsPhotoOverTenMegabytes()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var (_, dispatch) = await SeedDispatch(context);
|
||||||
|
|
||||||
|
var result = await NewController(context).UploadCompletionDocument(
|
||||||
|
dispatch.Id,
|
||||||
|
FormFile(MediaBytes(10_000_001, 0xFF, 0xD8, 0xFF, 0xE0), "photo.jpg", "image/jpeg"));
|
||||||
|
|
||||||
|
result.Should().BeOfType<BadRequestObjectResult>();
|
||||||
|
context.VendorCompletionDocuments.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadCompletionDocument_RejectsUnsupportedVideoContainer()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var (_, dispatch) = await SeedDispatch(context);
|
||||||
|
|
||||||
|
var result = await NewController(context).UploadCompletionDocument(
|
||||||
|
dispatch.Id,
|
||||||
|
FormFile("not a video at all"u8.ToArray(), "clip.mp4", "video/mp4"));
|
||||||
|
|
||||||
|
result.Should().BeOfType<BadRequestObjectResult>();
|
||||||
|
context.VendorCompletionDocuments.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetDispatchDetail_ReturnsUploadedDocumentWithQuarantineAndReplacementMetadata()
|
public async Task GetDispatchDetail_ReturnsUploadedDocumentWithQuarantineAndReplacementMetadata()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Api.SeaHavenIndustries.Controllers;
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
@ -31,7 +32,7 @@ public class WorkOrderMediaControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddMedia_HasTwoHundredMegabyteRequestLimit()
|
public void AddMedia_HasContractRequestLimit()
|
||||||
{
|
{
|
||||||
var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia));
|
var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia));
|
||||||
var attribute = Assert.Single(
|
var attribute = Assert.Single(
|
||||||
|
|
@ -39,36 +40,293 @@ public class WorkOrderMediaControllerTests
|
||||||
candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute));
|
candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute));
|
||||||
var bytes = Assert.Single(attribute.ConstructorArguments);
|
var bytes = Assert.Single(attribute.ConstructorArguments);
|
||||||
|
|
||||||
Assert.Equal(200_000_000L, bytes.Value);
|
Assert.Equal(110_000_000L, bytes.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddMedia_HasTwoHundredMegabyteMultipartBodyLimit()
|
public void AddMedia_HasContractMultipartBodyLimit()
|
||||||
{
|
{
|
||||||
var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia));
|
var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia));
|
||||||
var attribute = Assert.IsType<RequestFormLimitsAttribute>(Assert.Single(
|
var attribute = Assert.IsType<RequestFormLimitsAttribute>(Assert.Single(
|
||||||
method!.GetCustomAttributes(typeof(RequestFormLimitsAttribute), inherit: true)));
|
method!.GetCustomAttributes(typeof(RequestFormLimitsAttribute), inherit: true)));
|
||||||
|
|
||||||
Assert.Equal(200_000_000L, attribute.MultipartBodyLengthLimit);
|
Assert.Equal(110_000_000L, attribute.MultipartBodyLengthLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddMedia_FileOverLimit_ReturnsStableUnprocessableEntity()
|
public async Task AddMedia_VideoOverHundredMegabytes_ReturnsStableUnprocessableEntity()
|
||||||
{
|
{
|
||||||
var file = new Mock<IFormFile>();
|
var file = OversizeFile(100_000_001, "clip.mp4", "video/mp4", FtypHeader);
|
||||||
file.SetupGet(candidate => candidate.Length).Returns(200_000_001);
|
|
||||||
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
||||||
var controller = CreateController(storage: storage);
|
var controller = CreateController(storage: storage);
|
||||||
|
|
||||||
var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None);
|
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
|
||||||
|
|
||||||
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
||||||
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
|
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
|
||||||
Assert.Equal("FileTooLarge", error.Code);
|
Assert.Equal("FileTooLarge", error.Code);
|
||||||
Assert.Equal("The uploaded file must not exceed 200 MB.", error.Message);
|
Assert.Equal("Videos must be 100 MB or smaller.", error.Message);
|
||||||
storage.VerifyNoOtherCalls();
|
storage.VerifyNoOtherCalls();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_PhotoOverTenMegabytes_ReturnsStableUnprocessableEntity()
|
||||||
|
{
|
||||||
|
var file = OversizeFile(10_000_001, "photo.jpg", "image/jpeg", JpegHeader);
|
||||||
|
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
||||||
|
var controller = CreateController(storage: storage);
|
||||||
|
|
||||||
|
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
|
||||||
|
|
||||||
|
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
||||||
|
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
|
||||||
|
Assert.Equal("FileTooLarge", error.Code);
|
||||||
|
Assert.Equal("Photos must be 10 MB or smaller.", error.Message);
|
||||||
|
storage.VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_PhotoDeclaredAsForeignVideoType_IsSizedAsAPhoto()
|
||||||
|
{
|
||||||
|
// A .jpg with a foreign video type resolves to image/jpeg for validation, so it must
|
||||||
|
// also be sized as a photo, not given the 100 MB video allowance.
|
||||||
|
var file = OversizeFile(60_000_000, "photo.jpg", "video/3gpp", JpegHeader);
|
||||||
|
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
||||||
|
var controller = CreateController(storage: storage);
|
||||||
|
|
||||||
|
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
|
||||||
|
|
||||||
|
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
||||||
|
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
|
||||||
|
Assert.Equal("FileTooLarge", error.Code);
|
||||||
|
Assert.Equal("Photos must be 10 MB or smaller.", error.Message);
|
||||||
|
storage.VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_DocumentOverFiftyMegabytes_ReturnsStableUnprocessableEntity()
|
||||||
|
{
|
||||||
|
var file = OversizeFile(50_000_001, "report.pdf", "application/pdf", PdfHeader);
|
||||||
|
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
||||||
|
var controller = CreateController(storage: storage);
|
||||||
|
|
||||||
|
var result = await controller.AddMedia(1, WorkOrderMediaCategory.Extra, file, CancellationToken.None);
|
||||||
|
|
||||||
|
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
||||||
|
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
|
||||||
|
Assert.Equal("FileTooLarge", error.Code);
|
||||||
|
Assert.Equal("Documents must be 50 MB or smaller.", error.Message);
|
||||||
|
storage.VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_OversizeUnsupportedType_ReportsUnsupportedTypeNotOversizeVideo()
|
||||||
|
{
|
||||||
|
var file = OversizeFile(150_000_000, "payload.exe", "application/octet-stream", [0x4D, 0x5A]);
|
||||||
|
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
||||||
|
var controller = CreateController(storage: storage);
|
||||||
|
|
||||||
|
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
|
||||||
|
|
||||||
|
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
||||||
|
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
|
||||||
|
Assert.Equal("UnsupportedMediaType", error.Code);
|
||||||
|
storage.VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateSize_OversizeUnknownKind_UsesTypeNeutralMessage()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
"Files must be 100 MB or smaller.",
|
||||||
|
WorkOrderMediaContract.ValidateSize(150_000_000, WorkOrderMediaContract.UploadKind.Unknown));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly byte[] FtypHeader = [0, 0, 0, 0x18, (byte)'f', (byte)'t', (byte)'y', (byte)'p', (byte)'i', (byte)'s', (byte)'o', (byte)'m'];
|
||||||
|
private static readonly byte[] JpegHeader = [0xFF, 0xD8, 0xFF, 0xE0];
|
||||||
|
private static readonly byte[] PdfHeader = [0x25, 0x50, 0x44, 0x46, 0x2D];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A file that reports <paramref name="length"/> bytes but only backs the 512-byte header the
|
||||||
|
/// type rules read, so oversize cases run through real signature validation cheaply.
|
||||||
|
/// </summary>
|
||||||
|
private static FormFile OversizeFile(long length, string fileName, string contentType, byte[] header)
|
||||||
|
{
|
||||||
|
var bytes = new byte[512];
|
||||||
|
header.CopyTo(bytes, 0);
|
||||||
|
return new FormFile(new MemoryStream(bytes), 0, length, "file", fileName)
|
||||||
|
{
|
||||||
|
Headers = new HeaderDictionary(),
|
||||||
|
ContentType = contentType
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FormFile VideoFormFile(int size, string fileName, string contentType)
|
||||||
|
{
|
||||||
|
var bytes = new byte[size];
|
||||||
|
// ISO BMFF ftyp box so the media type rules accept the payload as MP4/MOV.
|
||||||
|
bytes[4] = (byte)'f';
|
||||||
|
bytes[5] = (byte)'t';
|
||||||
|
bytes[6] = (byte)'y';
|
||||||
|
bytes[7] = (byte)'p';
|
||||||
|
bytes[8] = (byte)'i';
|
||||||
|
bytes[9] = (byte)'s';
|
||||||
|
bytes[10] = (byte)'o';
|
||||||
|
bytes[11] = (byte)'m';
|
||||||
|
return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
|
||||||
|
{
|
||||||
|
Headers = new HeaderDictionary(),
|
||||||
|
ContentType = contentType
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (Mock<IWorkOrderMediaService> Service, Mock<IFileStoragePort> Storage) SetupSuccessfulAddMedia()
|
||||||
|
{
|
||||||
|
var service = new Mock<IWorkOrderMediaService>(MockBehavior.Strict);
|
||||||
|
service
|
||||||
|
.Setup(candidate => candidate.EnsureCanMutateMediaAsync(
|
||||||
|
1, It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(), It.IsAny<CancellationToken>(), null))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
service
|
||||||
|
.Setup(candidate => candidate.AddMediaAsync(
|
||||||
|
1, null, "https://storage.test/stored", It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new WorkOrderMediaFileDto { Id = 5, Url = "https://storage.test/stored" });
|
||||||
|
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
||||||
|
storage
|
||||||
|
.Setup(candidate => candidate.SaveFileAsync(It.IsAny<IFormFile>()))
|
||||||
|
.ReturnsAsync("https://storage.test/stored");
|
||||||
|
return (service, storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_SixtyMegabyteMp4_IsAccepted()
|
||||||
|
{
|
||||||
|
var (service, storage) = SetupSuccessfulAddMedia();
|
||||||
|
var controller = CreateController(service, storage);
|
||||||
|
var file = VideoFormFile(60_000_000, "site-clip.mp4", "video/mp4");
|
||||||
|
|
||||||
|
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result);
|
||||||
|
Assert.Equal(5, Assert.IsType<WorkOrderMediaFileDto>(ok.Value).Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_VideoLongerThanNinetySeconds_ReturnsStableUnprocessableEntity()
|
||||||
|
{
|
||||||
|
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
||||||
|
var controller = CreateController(storage: storage);
|
||||||
|
var file = PhoneVideo(durationSeconds: 95, "IMG_0042.MOV", "video/quicktime");
|
||||||
|
|
||||||
|
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
|
||||||
|
|
||||||
|
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
||||||
|
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
|
||||||
|
Assert.Equal("VideoTooLong", error.Code);
|
||||||
|
Assert.Equal("Videos must be 90 seconds or shorter.", error.Message);
|
||||||
|
storage.VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_VideoWithinNinetySeconds_IsAccepted()
|
||||||
|
{
|
||||||
|
var (service, storage) = SetupSuccessfulAddMedia();
|
||||||
|
var controller = CreateController(service, storage);
|
||||||
|
var file = PhoneVideo(durationSeconds: 60, "IMG_0043.MOV", "");
|
||||||
|
|
||||||
|
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<OkObjectResult>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ftyp + mdat + moov/mvhd (moov last, as phones write it).</summary>
|
||||||
|
private static FormFile PhoneVideo(uint durationSeconds, string fileName, string contentType)
|
||||||
|
{
|
||||||
|
static byte[] Box(string type, byte[] body)
|
||||||
|
{
|
||||||
|
var box = new byte[8 + body.Length];
|
||||||
|
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length);
|
||||||
|
System.Text.Encoding.ASCII.GetBytes(type).CopyTo(box, 4);
|
||||||
|
body.CopyTo(box, 8);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mvhd = new byte[20];
|
||||||
|
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(12), 1000);
|
||||||
|
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(16), durationSeconds * 1000);
|
||||||
|
var bytes = Box("ftyp", System.Text.Encoding.ASCII.GetBytes("qt \0\0\0\0"))
|
||||||
|
.Concat(Box("mdat", new byte[4096]))
|
||||||
|
.Concat(Box("moov", Box("mvhd", mvhd)))
|
||||||
|
.ToArray();
|
||||||
|
return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
|
||||||
|
{
|
||||||
|
Headers = new HeaderDictionary(),
|
||||||
|
ContentType = contentType
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_SixtyMegabyteQuicktimeMov_IsAccepted()
|
||||||
|
{
|
||||||
|
var (service, storage) = SetupSuccessfulAddMedia();
|
||||||
|
var controller = CreateController(service, storage);
|
||||||
|
var file = VideoFormFile(60_000_000, "site-clip.mov", "video/quicktime");
|
||||||
|
|
||||||
|
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<OkObjectResult>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_SixtyMegabyteMovWithEmptyContentType_IsAccepted()
|
||||||
|
{
|
||||||
|
var (service, storage) = SetupSuccessfulAddMedia();
|
||||||
|
var controller = CreateController(service, storage);
|
||||||
|
var file = VideoFormFile(60_000_000, "site-clip.MOV", "");
|
||||||
|
|
||||||
|
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<OkObjectResult>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_SixtyMegabyteMp4WithOctetStreamContentType_IsAccepted()
|
||||||
|
{
|
||||||
|
var (service, storage) = SetupSuccessfulAddMedia();
|
||||||
|
var controller = CreateController(service, storage);
|
||||||
|
var file = VideoFormFile(60_000_000, "site-clip.mp4", "application/octet-stream");
|
||||||
|
|
||||||
|
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<OkObjectResult>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_HeicPhoto_IsAccepted()
|
||||||
|
{
|
||||||
|
var (service, storage) = SetupSuccessfulAddMedia();
|
||||||
|
var controller = CreateController(service, storage);
|
||||||
|
var bytes = new byte[512];
|
||||||
|
bytes[4] = (byte)'f';
|
||||||
|
bytes[5] = (byte)'t';
|
||||||
|
bytes[6] = (byte)'y';
|
||||||
|
bytes[7] = (byte)'p';
|
||||||
|
bytes[8] = (byte)'h';
|
||||||
|
bytes[9] = (byte)'e';
|
||||||
|
bytes[10] = (byte)'i';
|
||||||
|
bytes[11] = (byte)'c';
|
||||||
|
var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "capture.heic")
|
||||||
|
{
|
||||||
|
Headers = new HeaderDictionary(),
|
||||||
|
ContentType = "image/heic"
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<OkObjectResult>(result);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddMedia_UnsupportedType_ReturnsUnprocessableEntity()
|
public async Task AddMedia_UnsupportedType_ReturnsUnprocessableEntity()
|
||||||
{
|
{
|
||||||
|
|
@ -167,7 +425,8 @@ public class WorkOrderMediaServiceCancellationTests
|
||||||
mediaData.Object,
|
mediaData.Object,
|
||||||
new Mock<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
|
new Mock<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
|
||||||
new Mock<IWorkOrderAuditService>(MockBehavior.Strict).Object,
|
new Mock<IWorkOrderAuditService>(MockBehavior.Strict).Object,
|
||||||
storage.Object);
|
storage.Object,
|
||||||
|
new Mock<IUpliftDataService>(MockBehavior.Strict).Object);
|
||||||
var user = new ClaimsPrincipal(new ClaimsIdentity(
|
var user = new ClaimsPrincipal(new ClaimsIdentity(
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
|
|
@ -184,4 +443,37 @@ public class WorkOrderMediaServiceCancellationTests
|
||||||
mediaData.Verify(candidate => candidate.GetAttachmentForReadAsync(10, 1, token), Times.Once);
|
mediaData.Verify(candidate => candidate.GetAttachmentForReadAsync(10, 1, token), Times.Once);
|
||||||
mediaData.VerifyNoOtherCalls();
|
mediaData.VerifyNoOtherCalls();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetMediaContent_ServesHeicAsImageHeic()
|
||||||
|
{
|
||||||
|
const string url = "https://example.test/Assets/Images/photo.heic";
|
||||||
|
var mediaData = new Mock<IWorkOrderMediaDataService>(MockBehavior.Strict);
|
||||||
|
mediaData.Setup(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new WorkOrder { Id = 1 });
|
||||||
|
mediaData.Setup(candidate => candidate.GetAttachmentForReadAsync(10, 1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = url });
|
||||||
|
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
||||||
|
storage.Setup(candidate => candidate.OpenRead(url)).Returns(new MemoryStream([1, 2, 3]));
|
||||||
|
var service = new WorkOrderMediaService(
|
||||||
|
mediaData.Object,
|
||||||
|
new Mock<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
|
||||||
|
new Mock<IWorkOrderAuditService>(MockBehavior.Strict).Object,
|
||||||
|
storage.Object,
|
||||||
|
new Mock<IUpliftDataService>(MockBehavior.Strict).Object);
|
||||||
|
var user = new ClaimsPrincipal(new ClaimsIdentity(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, "actor-1"),
|
||||||
|
new Claim(ClaimTypes.Role, "Admin"),
|
||||||
|
new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll)
|
||||||
|
},
|
||||||
|
"Test"));
|
||||||
|
|
||||||
|
var result = await service.GetMediaContentAsync(1, 10, user, "actor-1");
|
||||||
|
result.Content.Dispose();
|
||||||
|
|
||||||
|
Assert.Equal("image/heic", result.ContentType);
|
||||||
|
Assert.Equal("photo.heic", result.FileName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,75 @@ public sealed class WorkOrderUpliftControllerTests
|
||||||
Assert.Equal(12, created.Id);
|
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]
|
[Fact]
|
||||||
public async Task CancelUplift_NotFound_Returns404()
|
public async Task CancelUplift_NotFound_Returns404()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Moq;
|
||||||
|
using SeaHaven.DataServices.Dto;
|
||||||
|
using SeaHaven.DataServices.Exceptions;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public sealed class WorkOrderUpliftServiceConflictTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_DifferentWorkOrdersSharingDispatch_MapsConcurrentInsertConflict()
|
||||||
|
{
|
||||||
|
// Dispatch 10 is legitimately shared: work order 1 owns it and work order 2 links to
|
||||||
|
// it through DispatchWorkOrders, so the uplift dispatch resolves to it for both.
|
||||||
|
var sharedDispatch = new Dispatch
|
||||||
|
{
|
||||||
|
Id = 10,
|
||||||
|
WorkOrderId = 1,
|
||||||
|
Status = "Scheduled",
|
||||||
|
NTEAmount = 1000m,
|
||||||
|
DispatchWorkOrders = new List<DispatchWorkOrder>
|
||||||
|
{
|
||||||
|
new() { DispatchId = 10, WorkOrderId = 2 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var upliftData = new Mock<IUpliftDataService>();
|
||||||
|
upliftData.Setup(x => x.GetUpliftDispatchForWorkOrderAsync(
|
||||||
|
It.IsIn(1, 2),
|
||||||
|
10,
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(sharedDispatch);
|
||||||
|
var bothAtInsert = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var savesArrived = 0;
|
||||||
|
var winner = 0;
|
||||||
|
|
||||||
|
upliftData.Setup(x => x.ExecuteWorkOrderMutationAsync(
|
||||||
|
It.IsAny<int>(),
|
||||||
|
It.IsAny<Func<CancellationToken, Task<WorkOrderUpliftDto?>>>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.Returns<int, Func<CancellationToken, Task<WorkOrderUpliftDto?>>, CancellationToken>(
|
||||||
|
(_, work, cancellationToken) => work(cancellationToken));
|
||||||
|
upliftData.Setup(x => x.HasPendingForWorkOrderAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(false);
|
||||||
|
upliftData.Setup(x => x.HasActiveAsync(10, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(false);
|
||||||
|
upliftData.Setup(x => x.SumAutoApprovedAmountForWorkOrderAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(0m);
|
||||||
|
upliftData.Setup(x => x.StageAsync(It.IsAny<DispatchUpliftRequest>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
upliftData.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(async () =>
|
||||||
|
{
|
||||||
|
if (Interlocked.Increment(ref savesArrived) == 2)
|
||||||
|
bothAtInsert.TrySetResult();
|
||||||
|
|
||||||
|
await bothAtInsert.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
if (Interlocked.Exchange(ref winner, 1) != 0)
|
||||||
|
throw new UpliftDispatchConflictException();
|
||||||
|
});
|
||||||
|
|
||||||
|
var detailData = new Mock<IWorkOrderDetailDataService>();
|
||||||
|
detailData.Setup(x => x.ExistsAsync(It.IsAny<int>(), It.IsAny<CancellationToken>(), It.IsAny<int?>()))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
detailData.Setup(x => x.GetWorkOrderForMediaAsync(It.IsAny<int>(), It.IsAny<CancellationToken>(), It.IsAny<int?>()))
|
||||||
|
.ReturnsAsync((int workOrderId, CancellationToken _, int? _) => new WorkOrder
|
||||||
|
{
|
||||||
|
Id = workOrderId,
|
||||||
|
PrimaryDispatchId = 10,
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
});
|
||||||
|
|
||||||
|
var dispatchData = new Mock<IDispatchDataService>();
|
||||||
|
dispatchData.Setup(x => x.GetByIdAsync(10))
|
||||||
|
.ReturnsAsync(sharedDispatch);
|
||||||
|
dispatchData.Setup(x => x.StageAuditLogAsync(It.IsAny<WorkOrderAuditLog>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var accountResolver = new Mock<IWorkOrderAccountResolver>();
|
||||||
|
accountResolver.Setup(x => x.ResolveAccountFilter(It.IsAny<ClaimsPrincipal>())).Returns((int?)null);
|
||||||
|
var userData = new Mock<IUserDataService>();
|
||||||
|
userData.Setup(x => x.GetDisplayNamesByIdsAsync(It.IsAny<IEnumerable<string>>()))
|
||||||
|
.ReturnsAsync(new Dictionary<string, string>());
|
||||||
|
// SH-327: the requester must hold RequestUplifts before the create reaches the insert.
|
||||||
|
var permissionData = new Mock<ITeamPermissionOverrideDataService>();
|
||||||
|
permissionData.Setup(x => x.GetUserAsync("dispatcher-1", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new TeamPermissionUserData { UserId = "dispatcher-1", RoleName = "Dispatcher" });
|
||||||
|
|
||||||
|
var service = new WorkOrderUpliftService(
|
||||||
|
upliftData.Object,
|
||||||
|
dispatchData.Object,
|
||||||
|
detailData.Object,
|
||||||
|
accountResolver.Object,
|
||||||
|
userData.Object,
|
||||||
|
permissionData.Object,
|
||||||
|
new TeamPermissionPolicy(),
|
||||||
|
TimeProvider.System,
|
||||||
|
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions
|
||||||
|
{
|
||||||
|
UpliftTier1MaxUsd = 2500m,
|
||||||
|
Tier1Roles = new[] { "Approver" },
|
||||||
|
Tier2Roles = new[] { "Manager" },
|
||||||
|
}));
|
||||||
|
var user = new ClaimsPrincipal(new ClaimsIdentity(
|
||||||
|
new[] { new Claim(ClaimTypes.NameIdentifier, "dispatcher-1") },
|
||||||
|
"test"));
|
||||||
|
|
||||||
|
static async Task<(WorkOrderUpliftDto? Result, Exception? Error)> Capture(
|
||||||
|
Func<Task<WorkOrderUpliftDto?>> create)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return (await create(), null);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
return (null, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var outcomes = await Task.WhenAll(
|
||||||
|
Capture(() => service.CreateAsync(1, new CreateWorkOrderUpliftRequestDto { Amount = 500m }, user, CancellationToken.None)),
|
||||||
|
Capture(() => service.CreateAsync(2, new CreateWorkOrderUpliftRequestDto { Amount = 500m }, user, CancellationToken.None)));
|
||||||
|
|
||||||
|
Assert.Single(outcomes, outcome => outcome.Result != null);
|
||||||
|
Assert.IsType<UpliftConflictException>(Assert.Single(outcomes, outcome => outcome.Error != null).Error);
|
||||||
|
upliftData.Verify(x => x.HasActiveAsync(10, It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@ 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.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Controllers
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
@ -217,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" });
|
||||||
|
|
@ -294,7 +300,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
[HttpPost("dispatches/{id:int}/completion-documents")]
|
[HttpPost("dispatches/{id:int}/completion-documents")]
|
||||||
[HttpPost("dispatches/{id:int}/documents")]
|
[HttpPost("dispatches/{id:int}/documents")]
|
||||||
[RequestSizeLimit(10_000_000)]
|
[RequestSizeLimit(WorkOrderMediaContract.MaxUploadRequestBytes)]
|
||||||
public async Task<IActionResult> UploadCompletionDocument(
|
public async Task<IActionResult> UploadCompletionDocument(
|
||||||
int id,
|
int id,
|
||||||
[FromForm] IFormFile file,
|
[FromForm] IFormFile file,
|
||||||
|
|
|
||||||
|
|
@ -164,13 +164,20 @@ 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") });
|
||||||
}
|
}
|
||||||
catch (UpliftForbiddenException ex)
|
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)
|
catch (InvalidOperationException ex)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[Route("api/workorders")]
|
[Route("api/workorders")]
|
||||||
public class WorkOrderMediaController : Controller
|
public class WorkOrderMediaController : Controller
|
||||||
{
|
{
|
||||||
public const long MaxUploadBytes = 200_000_000;
|
public const long MaxUploadBytes = WorkOrderMediaContract.MaxUploadRequestBytes;
|
||||||
|
|
||||||
private readonly IWorkOrderMediaService _workOrderMediaService;
|
private readonly IWorkOrderMediaService _workOrderMediaService;
|
||||||
private readonly IFileStoragePort _fileStorage;
|
private readonly IFileStoragePort _fileStorage;
|
||||||
|
|
@ -88,14 +88,28 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
string? fileUrl = null;
|
string? fileUrl = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (file.Length > MaxUploadBytes)
|
// Type first, so an unsupported file always reports UnsupportedMediaType instead
|
||||||
|
// of being sized under a kind it does not have.
|
||||||
|
WorkOrderMediaFileRules.EnsureAllowed(file, category);
|
||||||
|
|
||||||
|
// Media contract: per-kind caps (photos 10 MB, videos 100 MB, documents 50 MB).
|
||||||
|
// Classify by the same resolved type EnsureAllowed validates against.
|
||||||
|
var sizeMessage = WorkOrderMediaContract.ValidateSize(
|
||||||
|
WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName, file.Length);
|
||||||
|
if (sizeMessage != null)
|
||||||
{
|
{
|
||||||
throw new WorkOrderBoardValidationException(
|
throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage);
|
||||||
"FileTooLarge",
|
|
||||||
"The uploaded file must not exceed 200 MB.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
WorkOrderMediaFileRules.EnsureAllowed(file, category);
|
if (WorkOrderMediaContract.ResolveKind(
|
||||||
|
WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName)
|
||||||
|
== WorkOrderMediaContract.UploadKind.Video)
|
||||||
|
{
|
||||||
|
using var content = file.OpenReadStream();
|
||||||
|
var durationMessage = WorkOrderMediaContract.ValidateVideoDuration(content);
|
||||||
|
if (durationMessage != null)
|
||||||
|
throw new WorkOrderBoardValidationException("VideoTooLong", durationMessage);
|
||||||
|
}
|
||||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
await _workOrderMediaService.EnsureCanMutateMediaAsync(id, User, actorId, cancellationToken, category);
|
await _workOrderMediaService.EnsureCanMutateMediaAsync(id, User, actorId, cancellationToken, category);
|
||||||
|
|
||||||
|
|
|
||||||
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,6 +45,9 @@ namespace Data.SeaHavenIndustries
|
||||||
public string? Status { get; set; }
|
public string? Status { get; set; }
|
||||||
public int RequiredTier { get; set; }
|
public int RequiredTier { get; set; }
|
||||||
public string? RequestedByVendorName { get; set; }
|
public string? RequestedByVendorName { get; set; }
|
||||||
|
// True when the vendor raised the request in the portal; false when it was raised
|
||||||
|
// from the work order. Only vendor-raised requests are revisable or withdrawable.
|
||||||
|
public bool RaisedByVendor { get; set; }
|
||||||
public DateTime? CreatedDate { get; set; }
|
public DateTime? CreatedDate { get; set; }
|
||||||
public DateTime? DecidedAt { get; set; }
|
public DateTime? DecidedAt { get; set; }
|
||||||
public string? DecisionNote { get; set; }
|
public string? DecisionNote { get; set; }
|
||||||
|
|
@ -100,6 +103,8 @@ namespace Data.SeaHavenIndustries
|
||||||
public string? DecidedByLastName { get; set; }
|
public string? DecidedByLastName { get; set; }
|
||||||
public decimal? CurrentNTE { get; set; }
|
public decimal? CurrentNTE { get; set; }
|
||||||
public decimal RequestedNTE { get; set; }
|
public decimal RequestedNTE { get; set; }
|
||||||
|
/// <summary>The requested NTE increase; see UpliftAmount in SeaHaven.DataServices.</summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
public string? VendorReason { get; set; }
|
public string? VendorReason { get; set; }
|
||||||
public int RequiredTier { get; set; }
|
public int RequiredTier { get; set; }
|
||||||
public string? Status { get; set; }
|
public string? Status { get; set; }
|
||||||
|
|
@ -127,6 +132,8 @@ namespace Data.SeaHavenIndustries
|
||||||
public int DispatchId { get; set; }
|
public int DispatchId { get; set; }
|
||||||
public decimal? CurrentNTE { get; set; }
|
public decimal? CurrentNTE { get; set; }
|
||||||
public decimal RequestedNTE { get; set; }
|
public decimal RequestedNTE { get; set; }
|
||||||
|
/// <summary>The requested NTE increase; see UpliftAmount in SeaHaven.DataServices.</summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
public string? VendorReason { get; set; }
|
public string? VendorReason { get; set; }
|
||||||
public string? Status { get; set; }
|
public string? Status { get; set; }
|
||||||
public int RequiredTier { get; set; }
|
public int RequiredTier { get; 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.")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
33
SeaHaven.DataServices/Helpers/UpliftAmount.cs
Normal file
33
SeaHaven.DataServices/Helpers/UpliftAmount.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
using System.Linq.Expressions;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The single definition of an uplift's amount: the NTE increase being requested.
|
||||||
|
/// The two creation paths store different meanings in RequestedNTE. Vendor-portal
|
||||||
|
/// requests store the requested new NTE total, so the amount is RequestedNTE minus
|
||||||
|
/// the NTE at request time. Work-order requests store the increase itself. Vendor
|
||||||
|
/// sessions have no identity user, so createdby is null only on vendor-portal rows.
|
||||||
|
/// </summary>
|
||||||
|
public static class UpliftAmount
|
||||||
|
{
|
||||||
|
/// <summary>Translatable form for EF queries and aggregates.</summary>
|
||||||
|
public static readonly Expression<Func<DispatchUpliftRequest, decimal>> ForQuery =
|
||||||
|
u => u.createdby == null
|
||||||
|
? u.RequestedNTE - (u.CurrentNTE ?? 0m)
|
||||||
|
: u.RequestedNTE;
|
||||||
|
|
||||||
|
private static readonly Func<DispatchUpliftRequest, decimal> Compiled = ForQuery.Compile();
|
||||||
|
|
||||||
|
public static decimal Of(DispatchUpliftRequest request) => Compiled(request);
|
||||||
|
|
||||||
|
public static decimal Of(string? createdBy, decimal requestedNte, decimal? currentNte) =>
|
||||||
|
Compiled(new DispatchUpliftRequest
|
||||||
|
{
|
||||||
|
createdby = createdBy,
|
||||||
|
RequestedNTE = requestedNte,
|
||||||
|
CurrentNTE = currentNte,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -255,6 +255,8 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
WorkOrderId = u.Dispatch!.WorkOrderId!.Value,
|
WorkOrderId = u.Dispatch!.WorkOrderId!.Value,
|
||||||
u.Status,
|
u.Status,
|
||||||
u.RequestedNTE,
|
u.RequestedNTE,
|
||||||
|
u.CurrentNTE,
|
||||||
|
u.createdby,
|
||||||
u.CreatedDate
|
u.CreatedDate
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -268,6 +270,8 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
WorkOrderId = link.WorkOrderId,
|
WorkOrderId = link.WorkOrderId,
|
||||||
u.Status,
|
u.Status,
|
||||||
u.RequestedNTE,
|
u.RequestedNTE,
|
||||||
|
u.CurrentNTE,
|
||||||
|
u.createdby,
|
||||||
u.CreatedDate
|
u.CreatedDate
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -276,7 +280,7 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
l.UpliftId,
|
l.UpliftId,
|
||||||
l.WorkOrderId,
|
l.WorkOrderId,
|
||||||
l.Status,
|
l.Status,
|
||||||
l.RequestedNTE,
|
UpliftAmount.Of(l.createdby, l.RequestedNTE, l.CurrentNTE),
|
||||||
l.CreatedDate)));
|
l.CreatedDate)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -316,7 +320,7 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
pendingCount,
|
pendingCount,
|
||||||
HasUplift: true,
|
HasUplift: true,
|
||||||
primary?.Status,
|
primary?.Status,
|
||||||
primary?.RequestedNTE);
|
primary?.Amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -337,7 +341,7 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
int UpliftId,
|
int UpliftId,
|
||||||
int WorkOrderId,
|
int WorkOrderId,
|
||||||
string Status,
|
string Status,
|
||||||
decimal RequestedNTE,
|
decimal Amount,
|
||||||
DateTime? CreatedDate);
|
DateTime? CreatedDate);
|
||||||
|
|
||||||
private sealed record BoardUpliftAgg(
|
private sealed record BoardUpliftAgg(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SeaHaven.DataServices.Dto;
|
using SeaHaven.DataServices.Dto;
|
||||||
|
using SeaHaven.DataServices.Helpers;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.DataServices.Models;
|
using SeaHaven.DataServices.Models;
|
||||||
|
|
||||||
|
|
@ -457,6 +458,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
u.Id,
|
u.Id,
|
||||||
u.CurrentNTE,
|
u.CurrentNTE,
|
||||||
u.RequestedNTE,
|
u.RequestedNTE,
|
||||||
|
u.createdby,
|
||||||
u.VendorReason,
|
u.VendorReason,
|
||||||
u.Status,
|
u.Status,
|
||||||
u.RequiredTier,
|
u.RequiredTier,
|
||||||
|
|
@ -474,7 +476,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
Id = u.Id,
|
Id = u.Id,
|
||||||
CurrentNTE = u.CurrentNTE,
|
CurrentNTE = u.CurrentNTE,
|
||||||
RequestedNTE = u.RequestedNTE,
|
RequestedNTE = u.RequestedNTE,
|
||||||
Delta = u.RequestedNTE - (u.CurrentNTE ?? 0m),
|
Delta = UpliftAmount.Of(u.createdby, u.RequestedNTE, u.CurrentNTE),
|
||||||
VendorReason = u.VendorReason,
|
VendorReason = u.VendorReason,
|
||||||
Status = u.Status,
|
Status = u.Status,
|
||||||
RequiredTier = u.RequiredTier,
|
RequiredTier = u.RequiredTier,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ 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.Helpers;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
namespace SeaHaven.DataServices.Implementation
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
|
@ -108,6 +110,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
DecidedByLastName = x.decUser != null ? x.decUser.LastName : null,
|
DecidedByLastName = x.decUser != null ? x.decUser.LastName : null,
|
||||||
CurrentNTE = x.u.CurrentNTE,
|
CurrentNTE = x.u.CurrentNTE,
|
||||||
RequestedNTE = x.u.RequestedNTE,
|
RequestedNTE = x.u.RequestedNTE,
|
||||||
|
Amount = UpliftAmount.Of(x.u.createdby, x.u.RequestedNTE, x.u.CurrentNTE),
|
||||||
VendorReason = x.u.VendorReason,
|
VendorReason = x.u.VendorReason,
|
||||||
RequiredTier = x.u.RequiredTier,
|
RequiredTier = x.u.RequiredTier,
|
||||||
Status = x.u.Status,
|
Status = x.u.Status,
|
||||||
|
|
@ -127,16 +130,6 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return (total, items);
|
return (total, items);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<decimal> GetPendingExposureAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return _context.DispatchUpliftRequests
|
|
||||||
.Where(u => (u.IsDeleted == null || u.IsDeleted == false)
|
|
||||||
&& u.Status == "Pending")
|
|
||||||
.SumAsync(u => u.createdby == null
|
|
||||||
? u.RequestedNTE - (u.CurrentNTE ?? 0m)
|
|
||||||
: u.RequestedNTE, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await (from u in _context.DispatchUpliftRequests
|
return await (from u in _context.DispatchUpliftRequests
|
||||||
|
|
@ -152,6 +145,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
DispatchId = u.DispatchId,
|
DispatchId = u.DispatchId,
|
||||||
CurrentNTE = u.CurrentNTE,
|
CurrentNTE = u.CurrentNTE,
|
||||||
RequestedNTE = u.RequestedNTE,
|
RequestedNTE = u.RequestedNTE,
|
||||||
|
Amount = UpliftAmount.Of(u.createdby, u.RequestedNTE, u.CurrentNTE),
|
||||||
VendorReason = u.VendorReason,
|
VendorReason = u.VendorReason,
|
||||||
Status = u.Status,
|
Status = u.Status,
|
||||||
RequiredTier = u.RequiredTier,
|
RequiredTier = u.RequiredTier,
|
||||||
|
|
@ -192,6 +186,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
DispatchId = u.DispatchId,
|
DispatchId = u.DispatchId,
|
||||||
CurrentNTE = u.CurrentNTE,
|
CurrentNTE = u.CurrentNTE,
|
||||||
RequestedNTE = u.RequestedNTE,
|
RequestedNTE = u.RequestedNTE,
|
||||||
|
Amount = UpliftAmount.Of(u.createdby, u.RequestedNTE, u.CurrentNTE),
|
||||||
VendorReason = u.VendorReason,
|
VendorReason = u.VendorReason,
|
||||||
Status = u.Status,
|
Status = u.Status,
|
||||||
RequiredTier = u.RequiredTier,
|
RequiredTier = u.RequiredTier,
|
||||||
|
|
@ -248,6 +243,9 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
Status = u.Status,
|
Status = u.Status,
|
||||||
RequiredTier = u.RequiredTier,
|
RequiredTier = u.RequiredTier,
|
||||||
RequestedByVendorName = u.RequestedByVendorName,
|
RequestedByVendorName = u.RequestedByVendorName,
|
||||||
|
// Vendor sessions have no identity user, so createdby is null
|
||||||
|
// only on requests the vendor raised in the portal.
|
||||||
|
RaisedByVendor = u.createdby == null,
|
||||||
CreatedDate = u.CreatedDate,
|
CreatedDate = u.CreatedDate,
|
||||||
DecidedAt = u.DecidedAt,
|
DecidedAt = u.DecidedAt,
|
||||||
DecisionNote = u.DecisionNote,
|
DecisionNote = u.DecisionNote,
|
||||||
|
|
@ -336,6 +334,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)
|
||||||
|
|
@ -382,25 +399,14 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.Where(u => (u.IsDeleted == null || u.IsDeleted == false)
|
.Where(u => (u.IsDeleted == null || u.IsDeleted == false)
|
||||||
&& dispatchIds.Contains(u.DispatchId)
|
&& dispatchIds.Contains(u.DispatchId)
|
||||||
&& (u.Status == "Approved" || u.Status == "NoApprovalRequired"))
|
&& (u.Status == "Approved" || u.Status == "NoApprovalRequired"))
|
||||||
.GroupBy(u => u.DispatchId)
|
// Summing each request's amount (the granted increase) keeps sequential
|
||||||
|
// approvals from double-counting whole NTE totals in both buckets.
|
||||||
|
.GroupBy(u => new { u.DispatchId, u.Status }, UpliftAmount.ForQuery)
|
||||||
.Select(g => new
|
.Select(g => new
|
||||||
{
|
{
|
||||||
DispatchId = g.Key,
|
g.Key.DispatchId,
|
||||||
// The two creation paths store different meanings in RequestedNTE.
|
g.Key.Status,
|
||||||
// Vendor-portal rows store the requested new NTE total, so the
|
Total = g.Sum()
|
||||||
// granted amount is RequestedNTE - CurrentNTE; work-order-path rows
|
|
||||||
// store the granted increment directly. Vendor sessions have no
|
|
||||||
// identity user, so createdby is null only on vendor-portal rows. Summing
|
|
||||||
// granted amounts keeps sequential approvals from double-counting whole
|
|
||||||
// NTE totals. This applies to both buckets.
|
|
||||||
AutoApproved = g.Where(x => x.Status == "NoApprovalRequired")
|
|
||||||
.Sum(x => (decimal?)(x.createdby == null
|
|
||||||
? x.RequestedNTE - (x.CurrentNTE ?? 0m)
|
|
||||||
: x.RequestedNTE)),
|
|
||||||
AdminApproved = g.Where(x => x.Status == "Approved")
|
|
||||||
.Sum(x => (decimal?)(x.createdby == null
|
|
||||||
? x.RequestedNTE - (x.CurrentNTE ?? 0m)
|
|
||||||
: x.RequestedNTE))
|
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
|
@ -418,17 +424,20 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
totals[workOrderId] = total;
|
totals[workOrderId] = total;
|
||||||
}
|
}
|
||||||
|
|
||||||
total.AutoApprovedTotal += sum.AutoApproved ?? 0m;
|
if (sum.Status == "NoApprovalRequired")
|
||||||
total.AdminApprovedTotal += sum.AdminApproved ?? 0m;
|
total.AutoApprovedTotal += sum.Total;
|
||||||
|
else
|
||||||
|
total.AdminApprovedTotal += sum.Total;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return totals.Values.ToList();
|
return totals.Values.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// SH-207: queue-wide pending exposure for the approvals header. Mirrors the
|
// Queue-wide pending exposure for the approvals header: the sum of the amount each
|
||||||
// approved-exposure convention (sum of RequestedNTE) over every non-deleted
|
// Pending queue row displays (UpliftAmount), over the same rows the Pending tab
|
||||||
// Pending request on a non-deleted dispatch, independent of page or filters.
|
// lists (non-deleted request on a non-deleted dispatch), independent of page or
|
||||||
|
// filters.
|
||||||
public async Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken)
|
public async Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await _context.DispatchUpliftRequests
|
return await _context.DispatchUpliftRequests
|
||||||
|
|
@ -436,7 +445,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
&& u.Status == "Pending"
|
&& u.Status == "Pending"
|
||||||
&& u.Dispatch != null
|
&& u.Dispatch != null
|
||||||
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false))
|
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false))
|
||||||
.SumAsync(u => (decimal?)u.RequestedNTE, cancellationToken) ?? 0m;
|
.Select(UpliftAmount.ForQuery)
|
||||||
|
.SumAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(
|
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(
|
||||||
|
|
@ -525,7 +535,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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,39 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
// supporting evidence never participates in completion versioning or replacement.
|
// supporting evidence never participates in completion versioning or replacement.
|
||||||
private const string CompletionPurpose = "Completion";
|
private const string CompletionPurpose = "Completion";
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<string>> ListActiveContentTypesForWorkOrderAsync(
|
||||||
|
int workOrderId,
|
||||||
|
int? excludingDocumentId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Uplift evidence also records the latest completion id in ReplacesDocumentId, so only
|
||||||
|
// a newer completion version supersedes a document.
|
||||||
|
return await _context.VendorCompletionDocuments
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(document => document.WorkOrderId == workOrderId
|
||||||
|
&& (document.IsDeleted == null || document.IsDeleted == false)
|
||||||
|
&& (excludingDocumentId == null || document.Id != excludingDocumentId)
|
||||||
|
&& !_context.VendorCompletionDocuments.Any(newer =>
|
||||||
|
newer.ReplacesDocumentId == document.Id
|
||||||
|
&& newer.Purpose == CompletionPurpose
|
||||||
|
&& (newer.IsDeleted == null || newer.IsDeleted == false)))
|
||||||
|
.Select(document => document.ContentType)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<string>> ListActiveWorkOrderAttachmentUrlsAsync(
|
||||||
|
int workOrderId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.workOrderAttachments
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(attachment => attachment.WorkorderId == workOrderId
|
||||||
|
&& attachment.IsDeleted != true
|
||||||
|
&& attachment.Attachments != null)
|
||||||
|
.Select(attachment => attachment.Attachments!)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<VendorCompletionDocument?> GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken)
|
public Task<VendorCompletionDocument?> GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return _context.VendorCompletionDocuments
|
return _context.VendorCompletionDocuments
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,36 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
public void TrackAttachment(WorkOrderAttachments attachment)
|
public void TrackAttachment(WorkOrderAttachments attachment)
|
||||||
=> _context.workOrderAttachments.Add(attachment);
|
=> _context.workOrderAttachments.Add(attachment);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<string>> ListActiveAttachmentUrlsAsync(
|
||||||
|
int workOrderId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var urls = await _context.workOrderAttachments
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true && a.Attachments != null)
|
||||||
|
.Select(a => a.Attachments!)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<string>> ListActiveVendorMediaContentTypesAsync(
|
||||||
|
int workOrderId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Uplift evidence also records the latest completion id in ReplacesDocumentId, so only
|
||||||
|
// a newer completion version supersedes a document.
|
||||||
|
return await _context.VendorCompletionDocuments
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(document => document.WorkOrderId == workOrderId
|
||||||
|
&& (document.IsDeleted == null || document.IsDeleted == false)
|
||||||
|
&& !_context.VendorCompletionDocuments.Any(newer =>
|
||||||
|
newer.ReplacesDocumentId == document.Id
|
||||||
|
&& newer.Purpose == "Completion"
|
||||||
|
&& (newer.IsDeleted == null || newer.IsDeleted == false)))
|
||||||
|
.Select(document => document.ContentType)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version)
|
public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version)
|
||||||
=> _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version;
|
=> _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
{
|
{
|
||||||
Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
|
Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
|
||||||
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken);
|
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken);
|
||||||
Task<decimal> GetPendingExposureAsync(CancellationToken cancellationToken);
|
|
||||||
Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
|
Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
|
||||||
Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||||
Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken);
|
Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken);
|
||||||
|
|
@ -19,6 +18,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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,19 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task<VendorCompletionDocument?> GetMetadataForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
|
Task<VendorCompletionDocument?> GetMetadataForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
|
||||||
Task<List<VendorCompletionDocument>> ListForVendorDispatchAsync(int dispatchId, int vendorId, CancellationToken cancellationToken);
|
Task<List<VendorCompletionDocument>> ListForVendorDispatchAsync(int dispatchId, int vendorId, CancellationToken cancellationToken);
|
||||||
Task<VendorCompletionDocument?> GetUpliftEvidenceAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
|
Task<VendorCompletionDocument?> GetUpliftEvidenceAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
|
||||||
|
/// <summary>
|
||||||
|
/// Content types of the work order's current vendor documents (not deleted, not replaced by a
|
||||||
|
/// newer completion version), optionally excluding one being replaced. Feeds the per-work-order photo/video counts.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<string>> ListActiveContentTypesForWorkOrderAsync(
|
||||||
|
int workOrderId,
|
||||||
|
int? excludingDocumentId,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Stored URLs of the work order's non-deleted dispatcher attachments (photo/video counts).</summary>
|
||||||
|
Task<IReadOnlyList<string>> ListActiveWorkOrderAttachmentUrlsAsync(
|
||||||
|
int workOrderId,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken);
|
Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken);
|
||||||
Task SaveChangesAsync(CancellationToken cancellationToken);
|
Task SaveChangesAsync(CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,19 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
|
|
||||||
Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken);
|
Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken);
|
||||||
void TrackAttachment(WorkOrderAttachments attachment);
|
void TrackAttachment(WorkOrderAttachments attachment);
|
||||||
|
|
||||||
|
/// <summary>Stored URLs of the work order's non-deleted attachments (photo/video counts).</summary>
|
||||||
|
Task<IReadOnlyList<string>> ListActiveAttachmentUrlsAsync(
|
||||||
|
int workOrderId,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Content types of the work order's current vendor-portal documents (not deleted, not
|
||||||
|
/// replaced by a newer completion version). The counts span both upload surfaces.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<string>> ListActiveVendorMediaContentTypesAsync(
|
||||||
|
int workOrderId,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
|
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
|
||||||
void MarkWorkOrderModified(WorkOrder workOrder);
|
void MarkWorkOrderModified(WorkOrder workOrder);
|
||||||
Task SaveAsync(CancellationToken cancellationToken);
|
Task SaveAsync(CancellationToken cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -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")]
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,9 @@ namespace SeaHaven.Services.DTOs
|
||||||
|
|
||||||
public class UpliftForbiddenException : Exception
|
public class UpliftForbiddenException : Exception
|
||||||
{
|
{
|
||||||
|
public const string RequestUpliftsDeniedMessage =
|
||||||
|
"Your role can't request uplifts on this work order.";
|
||||||
|
|
||||||
public UpliftForbiddenException(string message) : base(message) { }
|
public UpliftForbiddenException(string message) : base(message) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,8 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Status { get; set; }
|
public string? Status { get; set; }
|
||||||
public int RequiredTier { get; set; }
|
public int RequiredTier { get; set; }
|
||||||
public string? RequestedByVendorName { get; set; }
|
public string? RequestedByVendorName { get; set; }
|
||||||
|
// False for requests raised from the work order: the portal shows them read-only.
|
||||||
|
public bool RaisedByVendor { get; set; }
|
||||||
public DateTime? RequestedAt { get; set; }
|
public DateTime? RequestedAt { get; set; }
|
||||||
public DateTime? DecidedAt { get; set; }
|
public DateTime? DecidedAt { get; set; }
|
||||||
public string? DecisionNote { get; set; }
|
public string? DecisionNote { get; set; }
|
||||||
|
|
@ -134,6 +136,26 @@ namespace SeaHaven.Services.DTOs
|
||||||
public IEnumerable<PortalCommentDTO> Comments { get; set; } = Enumerable.Empty<PortalCommentDTO>();
|
public IEnumerable<PortalCommentDTO> Comments { get; set; } = Enumerable.Empty<PortalCommentDTO>();
|
||||||
public IEnumerable<PortalUpliftDTO> UpliftRequests { get; set; } = Enumerable.Empty<PortalUpliftDTO>();
|
public IEnumerable<PortalUpliftDTO> UpliftRequests { get; set; } = Enumerable.Empty<PortalUpliftDTO>();
|
||||||
public IEnumerable<VendorPortalDocumentDTO> Documents { get; set; } = Enumerable.Empty<VendorPortalDocumentDTO>();
|
public IEnumerable<VendorPortalDocumentDTO> Documents { get; set; } = Enumerable.Empty<VendorPortalDocumentDTO>();
|
||||||
|
public PortalMediaCountsDTO? MediaCounts { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-work-order photo/video usage, so the portal can pre-check an upload
|
||||||
|
/// before sending it. The server still enforces the limit on upload.
|
||||||
|
/// </summary>
|
||||||
|
public class PortalMediaCountsDTO
|
||||||
|
{
|
||||||
|
public int MaxPhotos { get; set; }
|
||||||
|
public int MaxVideos { get; set; }
|
||||||
|
/// <summary>Photos/videos on the work order, counted for evidence uploads.</summary>
|
||||||
|
public int Photos { get; set; }
|
||||||
|
public int Videos { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Photos/videos counted for a new completion version, which replaces the dispatch's
|
||||||
|
/// latest document and so does not count it.
|
||||||
|
/// </summary>
|
||||||
|
public int CompletionPhotos { get; set; }
|
||||||
|
public int CompletionVideos { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class VendorPortalDocumentDTO
|
public class VendorPortalDocumentDTO
|
||||||
|
|
|
||||||
|
|
@ -90,9 +90,11 @@ namespace SeaHaven.Services.DTOs
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// When true, rows with no date (no Schedule On) are returned alongside
|
/// When true, rows with no date (no Schedule On) are returned alongside
|
||||||
/// the in-range rows. Sent for searches with no date range selected; an
|
/// the in-range rows. Sent for searches with no date range selected; an
|
||||||
/// explicit range leaves it false so the range strictly narrows (SH-391).
|
/// explicit range leaves it unset or false so the range strictly narrows
|
||||||
|
/// (SH-391). When it is omitted, clients that predate the flag keep their
|
||||||
|
/// undated rows: see <see cref="WorkOrderSearchDateRangeResolver.ResolveIncludeDateless"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IncludeDateless { get; set; }
|
public bool? IncludeDateless { get; set; }
|
||||||
public List<string>? Sites { get; set; }
|
public List<string>? Sites { get; set; }
|
||||||
public List<string>? Regions { get; set; }
|
public List<string>? Regions { get; set; }
|
||||||
public List<WorkOrderType>? Types { get; set; }
|
public List<WorkOrderType>? Types { get; set; }
|
||||||
|
|
|
||||||
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.")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
129
SeaHaven.Services/Helpers/VideoDurationProbe.cs
Normal file
129
SeaHaven.Services/Helpers/VideoDurationProbe.cs
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a video's duration from the ISO base media (MP4 / QuickTime) movie header:
|
||||||
|
/// top-level <c>moov</c> box → <c>mvhd</c> timescale and duration. It seeks over box
|
||||||
|
/// headers only (the <c>moov</c> box may sit after a large <c>mdat</c>), never decodes
|
||||||
|
/// media and never allocates more than a few bytes. Returns null whenever the structure
|
||||||
|
/// cannot be read, so callers can let unreadable files through (per the media contract: unreadable
|
||||||
|
/// metadata never blocks an upload).
|
||||||
|
/// </summary>
|
||||||
|
public static class VideoDurationProbe
|
||||||
|
{
|
||||||
|
private const int MaxBoxesPerLevel = 1024;
|
||||||
|
|
||||||
|
public static double? TryReadDurationSeconds(Stream? stream)
|
||||||
|
{
|
||||||
|
if (stream == null || !stream.CanSeek || !stream.CanRead)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var moov = FindBox(stream, 0, stream.Length, "moov");
|
||||||
|
if (moov == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var mvhd = FindBox(stream, moov.Value.BodyStart, moov.Value.End, "mvhd");
|
||||||
|
return mvhd == null ? null : ReadMovieHeaderSeconds(stream, mvhd.Value);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct Box(long BodyStart, long End);
|
||||||
|
|
||||||
|
private static Box? FindBox(Stream stream, long start, long end, string type)
|
||||||
|
{
|
||||||
|
var header = new byte[8];
|
||||||
|
var position = start;
|
||||||
|
for (var i = 0; i < MaxBoxesPerLevel && position + 8 <= end; i++)
|
||||||
|
{
|
||||||
|
stream.Position = position;
|
||||||
|
if (!ReadExactly(stream, header, 8))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
long size = BinaryPrimitives.ReadUInt32BigEndian(header);
|
||||||
|
var boxType = Encoding.ASCII.GetString(header, 4, 4);
|
||||||
|
var headerLength = 8;
|
||||||
|
if (size == 1)
|
||||||
|
{
|
||||||
|
// 64-bit "largesize" follows the type.
|
||||||
|
var large = new byte[8];
|
||||||
|
if (!ReadExactly(stream, large, 8))
|
||||||
|
return null;
|
||||||
|
var largeSize = BinaryPrimitives.ReadUInt64BigEndian(large);
|
||||||
|
if (largeSize > long.MaxValue)
|
||||||
|
return null;
|
||||||
|
size = (long)largeSize;
|
||||||
|
headerLength = 16;
|
||||||
|
}
|
||||||
|
else if (size == 0)
|
||||||
|
{
|
||||||
|
size = end - position;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size < headerLength || position + size > end)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (boxType == type)
|
||||||
|
return new Box(position + headerLength, position + size);
|
||||||
|
|
||||||
|
position += size;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double? ReadMovieHeaderSeconds(Stream stream, Box mvhd)
|
||||||
|
{
|
||||||
|
// version(1) flags(3), then v0: creation(4) modification(4) timescale(4) duration(4)
|
||||||
|
// v1: creation(8) modification(8) timescale(4) duration(8)
|
||||||
|
var body = new byte[32];
|
||||||
|
stream.Position = mvhd.BodyStart;
|
||||||
|
var available = (int)Math.Min(body.Length, mvhd.End - mvhd.BodyStart);
|
||||||
|
if (available < 20 || !ReadExactly(stream, body, available))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
uint timescale;
|
||||||
|
ulong duration;
|
||||||
|
if (body[0] == 0)
|
||||||
|
{
|
||||||
|
timescale = BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(12, 4));
|
||||||
|
duration = BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(16, 4));
|
||||||
|
}
|
||||||
|
else if (body[0] == 1 && available >= 32)
|
||||||
|
{
|
||||||
|
timescale = BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(20, 4));
|
||||||
|
duration = BinaryPrimitives.ReadUInt64BigEndian(body.AsSpan(24, 8));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All-ones duration means "unknown" in the spec.
|
||||||
|
if (timescale == 0 || duration == uint.MaxValue || duration == ulong.MaxValue)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return (double)duration / timescale;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ReadExactly(Stream stream, byte[] buffer, int count)
|
||||||
|
{
|
||||||
|
var read = 0;
|
||||||
|
while (read < count)
|
||||||
|
{
|
||||||
|
var n = stream.Read(buffer, read, count - read);
|
||||||
|
if (n <= 0)
|
||||||
|
return false;
|
||||||
|
read += n;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
149
SeaHaven.Services/Helpers/WorkOrderMediaContract.cs
Normal file
149
SeaHaven.Services/Helpers/WorkOrderMediaContract.cs
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Client-confirmed media contract (2026-09-22): photos up to 10 MB (JPEG/PNG/HEIC),
|
||||||
|
/// videos up to 100 MB and 90 seconds (MP4/MOV), at most 10 photos and 3 videos per work
|
||||||
|
/// order, across the dispatcher media modal, the completion-doc media tab and the vendor
|
||||||
|
/// portal upload. Documents (PDF/DOC/DOCX) keep the 50 MB document cap. Duration is read
|
||||||
|
/// from the MP4/MOV movie header (<see cref="VideoDurationProbe"/>); the browser pre-checks
|
||||||
|
/// it and the server enforces it, and unreadable metadata never blocks an upload.
|
||||||
|
/// </summary>
|
||||||
|
public static class WorkOrderMediaContract
|
||||||
|
{
|
||||||
|
public const long MaxPhotoBytes = 10_000_000;
|
||||||
|
public const long MaxVideoBytes = 100_000_000;
|
||||||
|
public const long MaxDocumentBytes = 50_000_000;
|
||||||
|
public const int MaxPhotosPerWorkOrder = 10;
|
||||||
|
public const int MaxVideosPerWorkOrder = 3;
|
||||||
|
public const int MaxVideoDurationSeconds = 90;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request-level ceiling for media endpoints (RequestSizeLimit / multipart limit). It sits
|
||||||
|
/// above <see cref="MaxVideoBytes"/> plus multipart overhead so an oversize file reaches the
|
||||||
|
/// per-kind check and gets its generic message instead of a framework 413. The EB nginx
|
||||||
|
/// proxy (.platform/nginx/conf.d/01_upload_body_size.conf) must stay above this value.
|
||||||
|
/// </summary>
|
||||||
|
public const long MaxUploadRequestBytes = 110_000_000;
|
||||||
|
|
||||||
|
public enum UploadKind
|
||||||
|
{
|
||||||
|
Photo,
|
||||||
|
Video,
|
||||||
|
Document,
|
||||||
|
Unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, UploadKind> KindByContentType =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["image/jpeg"] = UploadKind.Photo,
|
||||||
|
["image/jpg"] = UploadKind.Photo,
|
||||||
|
["image/png"] = UploadKind.Photo,
|
||||||
|
["image/heic"] = UploadKind.Photo,
|
||||||
|
["video/mp4"] = UploadKind.Video,
|
||||||
|
["video/quicktime"] = UploadKind.Video,
|
||||||
|
["application/pdf"] = UploadKind.Document,
|
||||||
|
["application/msword"] = UploadKind.Document,
|
||||||
|
["application/vnd.openxmlformats-officedocument.wordprocessingml.document"] =
|
||||||
|
UploadKind.Document,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, UploadKind> KindByExtension =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
[".jpg"] = UploadKind.Photo,
|
||||||
|
[".jpeg"] = UploadKind.Photo,
|
||||||
|
[".png"] = UploadKind.Photo,
|
||||||
|
[".heic"] = UploadKind.Photo,
|
||||||
|
[".mp4"] = UploadKind.Video,
|
||||||
|
[".mov"] = UploadKind.Video,
|
||||||
|
[".pdf"] = UploadKind.Document,
|
||||||
|
[".doc"] = UploadKind.Document,
|
||||||
|
[".docx"] = UploadKind.Document,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Classifies an upload. Pass the validated (resolved) content type: it decides whenever
|
||||||
|
/// it is an allowlisted type, so a misleading file name cannot move a file into a larger
|
||||||
|
/// size class. The extension only decides when no allowlisted type is known (for example
|
||||||
|
/// counting stored attachment URLs).
|
||||||
|
/// </summary>
|
||||||
|
public static UploadKind ResolveKind(string? contentType, string? fileName)
|
||||||
|
{
|
||||||
|
var type = (contentType ?? string.Empty).Trim();
|
||||||
|
if (KindByContentType.TryGetValue(type, out var byType))
|
||||||
|
return byType;
|
||||||
|
|
||||||
|
var extension = Path.GetExtension(fileName ?? string.Empty);
|
||||||
|
return KindByExtension.TryGetValue(extension, out var byExtension)
|
||||||
|
? byExtension
|
||||||
|
: UploadKind.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-work-order count check across both upload surfaces: dispatcher attachments
|
||||||
|
/// (classified by stored URL) and vendor-portal documents (classified by validated
|
||||||
|
/// content type). Returns the stable rejection message, or null when there is room.
|
||||||
|
/// </summary>
|
||||||
|
public static string? ValidateCount(
|
||||||
|
UploadKind kind,
|
||||||
|
IEnumerable<string> attachmentUrls,
|
||||||
|
IEnumerable<string> vendorContentTypes)
|
||||||
|
{
|
||||||
|
if (kind != UploadKind.Photo && kind != UploadKind.Video)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var (photos, videos) = CountKinds(attachmentUrls, vendorContentTypes);
|
||||||
|
|
||||||
|
if (kind == UploadKind.Photo && photos >= MaxPhotosPerWorkOrder)
|
||||||
|
return "A work order can have at most 10 photos.";
|
||||||
|
if (kind == UploadKind.Video && videos >= MaxVideosPerWorkOrder)
|
||||||
|
return "A work order can have at most 3 videos.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Photos and videos on a work order: dispatcher attachments (kind from the stored URL)
|
||||||
|
/// plus current vendor-portal documents (kind from the validated content type).
|
||||||
|
/// </summary>
|
||||||
|
public static (int Photos, int Videos) CountKinds(
|
||||||
|
IEnumerable<string> attachmentUrls,
|
||||||
|
IEnumerable<string> vendorContentTypes)
|
||||||
|
{
|
||||||
|
var kinds = attachmentUrls.Select(url => ResolveKind(null, url))
|
||||||
|
.Concat(vendorContentTypes.Select(type => ResolveKind(type, null)))
|
||||||
|
.ToList();
|
||||||
|
return (kinds.Count(k => k == UploadKind.Photo), kinds.Count(k => k == UploadKind.Video));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 90-second video limit, read from the MP4/QuickTime movie header. A video whose
|
||||||
|
/// duration cannot be read is not blocked. Returns the stable message or null.
|
||||||
|
/// </summary>
|
||||||
|
public static string? ValidateVideoDuration(Stream? content)
|
||||||
|
{
|
||||||
|
var seconds = VideoDurationProbe.TryReadDurationSeconds(content);
|
||||||
|
return seconds > MaxVideoDurationSeconds
|
||||||
|
? $"Videos must be {MaxVideoDurationSeconds} seconds or shorter."
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stable, generic per-kind size message; never echoes file metadata.</summary>
|
||||||
|
public static string? ValidateSize(long length, UploadKind kind)
|
||||||
|
{
|
||||||
|
return kind switch
|
||||||
|
{
|
||||||
|
UploadKind.Photo when length > MaxPhotoBytes => "Photos must be 10 MB or smaller.",
|
||||||
|
UploadKind.Video when length > MaxVideoBytes => "Videos must be 100 MB or smaller.",
|
||||||
|
UploadKind.Document when length > MaxDocumentBytes =>
|
||||||
|
"Documents must be 50 MB or smaller.",
|
||||||
|
UploadKind.Unknown when length > MaxVideoBytes =>
|
||||||
|
"Files must be 100 MB or smaller.",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? ValidateSize(string? contentType, string? fileName, long length)
|
||||||
|
=> ValidateSize(length, ResolveKind(contentType, fileName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
"image/jpg",
|
"image/jpg",
|
||||||
"image/png",
|
"image/png",
|
||||||
|
"image/heic",
|
||||||
"video/mp4",
|
"video/mp4",
|
||||||
"video/quicktime",
|
"video/quicktime",
|
||||||
"application/pdf",
|
"application/pdf",
|
||||||
|
|
@ -24,6 +25,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
{
|
{
|
||||||
["image/jpeg"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg" },
|
["image/jpeg"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg" },
|
||||||
["image/png"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png" },
|
["image/png"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png" },
|
||||||
|
["image/heic"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".heic" },
|
||||||
["video/mp4"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mp4" },
|
["video/mp4"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mp4" },
|
||||||
["video/quicktime"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mov" },
|
["video/quicktime"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mov" },
|
||||||
["application/pdf"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".pdf" },
|
["application/pdf"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".pdf" },
|
||||||
|
|
@ -42,7 +44,11 @@ namespace SeaHaven.Services.Helpers
|
||||||
// the accepted set of files.
|
// the accepted set of files.
|
||||||
private static string? ResolveContentType(string declaredType, string extension)
|
private static string? ResolveContentType(string declaredType, string extension)
|
||||||
{
|
{
|
||||||
if (AllowedContentTypes.Contains(declaredType))
|
// iOS transcodes a picked HEIC photo to JPEG (named .jpg) but can keep image/heic as
|
||||||
|
// its type, so a declared image/heic is only authoritative on a real .heic file.
|
||||||
|
var isTranscodedHeic = declaredType.Equals("image/heic", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !extension.Equals(".heic", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (AllowedContentTypes.Contains(declaredType) && !isTranscodedHeic)
|
||||||
return declaredType;
|
return declaredType;
|
||||||
|
|
||||||
foreach (var (contentType, extensions) in ExtensionsByContentType)
|
foreach (var (contentType, extensions) in ExtensionsByContentType)
|
||||||
|
|
@ -61,6 +67,19 @@ namespace SeaHaven.Services.Helpers
|
||||||
return contentType;
|
return contentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The canonical content type <see cref="IsAllowed"/> validates the file as, or null when
|
||||||
|
/// no allowlisted type applies. Size classification must use this same type so a declared
|
||||||
|
/// type or a misleading extension cannot move a file into a larger size class.
|
||||||
|
/// </summary>
|
||||||
|
public static string? ResolveUploadContentType(IFormFile file)
|
||||||
|
{
|
||||||
|
var declaredType = (file.ContentType ?? string.Empty).Trim();
|
||||||
|
var extension = Path.GetExtension(file.FileName ?? string.Empty);
|
||||||
|
var resolvedType = ResolveContentType(declaredType, extension);
|
||||||
|
return resolvedType == null ? null : CanonicalContentType(resolvedType);
|
||||||
|
}
|
||||||
|
|
||||||
public static bool IsAllowed(
|
public static bool IsAllowed(
|
||||||
IFormFile file,
|
IFormFile file,
|
||||||
WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra)
|
WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra)
|
||||||
|
|
@ -68,13 +87,11 @@ namespace SeaHaven.Services.Helpers
|
||||||
if (file == null || file.Length <= 0)
|
if (file == null || file.Length <= 0)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var declaredType = (file.ContentType ?? string.Empty).Trim();
|
|
||||||
var extension = Path.GetExtension(file.FileName ?? string.Empty);
|
var extension = Path.GetExtension(file.FileName ?? string.Empty);
|
||||||
var resolvedType = ResolveContentType(declaredType, extension);
|
var contentType = ResolveUploadContentType(file);
|
||||||
if (resolvedType == null)
|
if (contentType == null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var contentType = CanonicalContentType(resolvedType);
|
|
||||||
var resolvedCategory = category ?? WorkOrderMediaCategory.Extra;
|
var resolvedCategory = category ?? WorkOrderMediaCategory.Extra;
|
||||||
if (IsDocument(contentType)
|
if (IsDocument(contentType)
|
||||||
&& resolvedCategory is not WorkOrderMediaCategory.Extra and not WorkOrderMediaCategory.Aveta)
|
&& resolvedCategory is not WorkOrderMediaCategory.Extra and not WorkOrderMediaCategory.Aveta)
|
||||||
|
|
@ -123,7 +140,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
{
|
{
|
||||||
throw new Exceptions.WorkOrderBoardValidationException(
|
throw new Exceptions.WorkOrderBoardValidationException(
|
||||||
"UnsupportedMediaType",
|
"UnsupportedMediaType",
|
||||||
"Supported file types are JPG, PNG, MP4, MOV, PDF, DOC, and DOCX for Extra Docs; photos accept media only.");
|
"Supported file types are JPG, PNG, HEIC, MP4, MOV, PDF, DOC, and DOCX for Extra Docs; photos accept media only.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,6 +156,11 @@ namespace SeaHaven.Services.Helpers
|
||||||
&& bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A;
|
&& bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (contentType.Equals("image/heic", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return IsHeifBrand(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase))
|
if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
|
return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
|
||||||
|
|
@ -206,5 +228,20 @@ namespace SeaHaven.Services.Helpers
|
||||||
&& bytes[6] == (byte)'y'
|
&& bytes[6] == (byte)'y'
|
||||||
&& bytes[7] == (byte)'p';
|
&& bytes[7] == (byte)'p';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsHeifBrand(byte[] bytes)
|
||||||
|
{
|
||||||
|
if (!HasFtypBox(bytes))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// HEIC/HEIF containers identify by the ftyp major brand (bytes 8..11).
|
||||||
|
var brand = System.Text.Encoding.ASCII.GetString(bytes, 8, 4);
|
||||||
|
return brand switch
|
||||||
|
{
|
||||||
|
"heic" or "heix" or "hevc" or "hevx" or "heim" or "heis" or "hevm" or "hevs"
|
||||||
|
or "mif1" or "msf1" => true,
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,36 @@ namespace SeaHaven.Services.Helpers
|
||||||
|
|
||||||
public static class WorkOrderSearchDateRangeResolver
|
public static class WorkOrderSearchDateRangeResolver
|
||||||
{
|
{
|
||||||
|
// Widest windows the frontend sends for "every week": the no-range search
|
||||||
|
// (ADVANCED_SEARCH_ALL_WEEKS_FROM/TO, 2000-01-01..2099-12-31) and the pinned
|
||||||
|
// Unassigned queue (UNASSIGNED_QUEUE_DATE_FROM/TO, 1970-01-01..2099-12-31).
|
||||||
|
private static readonly DateOnly AllWeeksFrom = new(2000, 1, 1);
|
||||||
|
private static readonly DateOnly AllWeeksTo = new(2099, 12, 31);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether a board search adds open undated rows to its date range (SH-391).
|
||||||
|
/// An explicit <paramref name="includeDateless"/> wins. When it is omitted,
|
||||||
|
/// as by clients that predate the flag, a search with no date input or with
|
||||||
|
/// an all-weeks Custom window still returns undated rows, as before SH-391:
|
||||||
|
/// those requests mean "no range selected". Any other range is strict.
|
||||||
|
/// </summary>
|
||||||
|
public static bool ResolveIncludeDateless(
|
||||||
|
bool? includeDateless,
|
||||||
|
WorkOrderAdvancedSearchDatePreset? preset,
|
||||||
|
DateOnly? dateFrom,
|
||||||
|
DateOnly? dateTo)
|
||||||
|
{
|
||||||
|
if (includeDateless.HasValue)
|
||||||
|
return includeDateless.Value;
|
||||||
|
|
||||||
|
if (!preset.HasValue && !dateFrom.HasValue && !dateTo.HasValue)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return preset == WorkOrderAdvancedSearchDatePreset.Custom
|
||||||
|
&& dateFrom <= AllWeeksFrom
|
||||||
|
&& dateTo >= AllWeeksTo;
|
||||||
|
}
|
||||||
|
|
||||||
public static WorkOrderSearchDateRange Resolve(
|
public static WorkOrderSearchDateRange Resolve(
|
||||||
WorkOrderAdvancedSearchDatePreset? preset,
|
WorkOrderAdvancedSearchDatePreset? preset,
|
||||||
DateOnly? dateFrom,
|
DateOnly? dateFrom,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
using SeaHaven.DataServices.Helpers;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Implementation;
|
using SeaHaven.Services.Implementation;
|
||||||
|
|
@ -42,7 +43,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
{
|
{
|
||||||
Id = row.Id,
|
Id = row.Id,
|
||||||
Status = ToFrontendStatus(row.Status),
|
Status = ToFrontendStatus(row.Status),
|
||||||
Amount = row.RequestedNTE,
|
Amount = row.Amount,
|
||||||
Notes = row.VendorReason ?? "",
|
Notes = row.VendorReason ?? "",
|
||||||
RequestedAt = row.CreatedDate,
|
RequestedAt = row.CreatedDate,
|
||||||
RequestedById = row.CreatedByUserId,
|
RequestedById = row.CreatedByUserId,
|
||||||
|
|
@ -58,7 +59,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
{
|
{
|
||||||
Id = request.Id,
|
Id = request.Id,
|
||||||
Status = ToFrontendStatus(request.Status),
|
Status = ToFrontendStatus(request.Status),
|
||||||
Amount = request.RequestedNTE,
|
Amount = UpliftAmount.Of(request),
|
||||||
Notes = request.VendorReason ?? "",
|
Notes = request.VendorReason ?? "",
|
||||||
RequestedAt = request.CreatedDate,
|
RequestedAt = request.CreatedDate,
|
||||||
RequestedById = request.createdby,
|
RequestedById = request.createdby,
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,9 @@ namespace SeaHaven.Services.Implementation
|
||||||
req.DecidedAt = now;
|
req.DecidedAt = now;
|
||||||
req.LastModificationTime = 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",
|
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
|
||||||
OldValue = previous,
|
OldValue = previous,
|
||||||
NewValue = UpliftStatus.Expired,
|
NewValue = UpliftStatus.Expired,
|
||||||
|
|
@ -136,9 +136,9 @@ namespace SeaHaven.Services.Implementation
|
||||||
req.EscalatedAt = now;
|
req.EscalatedAt = now;
|
||||||
req.LastModificationTime = 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",
|
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
|
||||||
OldValue = "Pending",
|
OldValue = "Pending",
|
||||||
NewValue = "Escalated",
|
NewValue = "Escalated",
|
||||||
|
|
@ -247,5 +247,19 @@ namespace SeaHaven.Services.Implementation
|
||||||
req.LastModificationTime = now;
|
req.LastModificationTime = now;
|
||||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
using SeaHaven.DataServices.Helpers;
|
||||||
|
|
||||||
namespace SeaHaven.Services.Implementation
|
namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
|
|
@ -23,7 +24,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
<table style='border-collapse:collapse;font-family:Arial,sans-serif;'>
|
<table style='border-collapse:collapse;font-family:Arial,sans-serif;'>
|
||||||
<tr><td style='padding:4px 10px;'><strong>Current NTE</strong></td><td style='padding:4px 10px;'>${req.CurrentNTE ?? 0m:F2}</td></tr>
|
<tr><td style='padding:4px 10px;'><strong>Current NTE</strong></td><td style='padding:4px 10px;'>${req.CurrentNTE ?? 0m:F2}</td></tr>
|
||||||
<tr><td style='padding:4px 10px;'><strong>Requested NTE</strong></td><td style='padding:4px 10px;'>${req.RequestedNTE:F2}</td></tr>
|
<tr><td style='padding:4px 10px;'><strong>Requested NTE</strong></td><td style='padding:4px 10px;'>${req.RequestedNTE:F2}</td></tr>
|
||||||
<tr><td style='padding:4px 10px;'><strong>Delta</strong></td><td style='padding:4px 10px;'>${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}</td></tr>
|
<tr><td style='padding:4px 10px;'><strong>Delta</strong></td><td style='padding:4px 10px;'>${UpliftAmount.Of(req):F2}</td></tr>
|
||||||
<tr><td style='padding:4px 10px;'><strong>Required Approval</strong></td><td style='padding:4px 10px;'>{tierText}</td></tr>
|
<tr><td style='padding:4px 10px;'><strong>Required Approval</strong></td><td style='padding:4px 10px;'>{tierText}</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<h3>Vendor Reason</h3>
|
<h3>Vendor Reason</h3>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHaven.DataServices.Helpers;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.Configuration;
|
using SeaHaven.Services.Configuration;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
@ -36,7 +37,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
public async Task<UpliftListResultDTO> ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken)
|
public async Task<UpliftListResultDTO> ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var (total, items) = await _upliftData.GetPagedAsync(status, tier, page, pageSize, cancellationToken);
|
var (total, items) = await _upliftData.GetPagedAsync(status, tier, page, pageSize, cancellationToken);
|
||||||
var pendingExposureTotal = await _upliftData.GetPendingExposureAsync(cancellationToken);
|
var pendingExposureTotal = await _upliftData.GetPendingExposureTotalAsync(cancellationToken);
|
||||||
|
|
||||||
var mapped = items.Select(r => new UpliftListItemDTO
|
var mapped = items.Select(r => new UpliftListItemDTO
|
||||||
{
|
{
|
||||||
|
|
@ -56,7 +57,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
DecidedByName = ResolveRequestedByName(null, r.DecidedByFirstName, r.DecidedByLastName),
|
DecidedByName = ResolveRequestedByName(null, r.DecidedByFirstName, r.DecidedByLastName),
|
||||||
CurrentNTE = r.CurrentNTE,
|
CurrentNTE = r.CurrentNTE,
|
||||||
RequestedNTE = r.RequestedNTE,
|
RequestedNTE = r.RequestedNTE,
|
||||||
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
|
Delta = r.Amount,
|
||||||
VendorReason = r.VendorReason,
|
VendorReason = r.VendorReason,
|
||||||
RequiredTier = r.RequiredTier,
|
RequiredTier = r.RequiredTier,
|
||||||
Status = UpliftStatus.ToCanonical(r.Status),
|
Status = UpliftStatus.ToCanonical(r.Status),
|
||||||
|
|
@ -115,7 +116,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
DispatchId = r.DispatchId,
|
DispatchId = r.DispatchId,
|
||||||
CurrentNTE = r.CurrentNTE,
|
CurrentNTE = r.CurrentNTE,
|
||||||
RequestedNTE = r.RequestedNTE,
|
RequestedNTE = r.RequestedNTE,
|
||||||
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
|
Delta = r.Amount,
|
||||||
VendorReason = r.VendorReason,
|
VendorReason = r.VendorReason,
|
||||||
Status = UpliftStatus.ToCanonical(r.Status),
|
Status = UpliftStatus.ToCanonical(r.Status),
|
||||||
RequiredTier = r.RequiredTier,
|
RequiredTier = r.RequiredTier,
|
||||||
|
|
@ -157,7 +158,12 @@ namespace SeaHaven.Services.Implementation
|
||||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||||
var oldNTE = dispatch.NTEAmount ?? 0m;
|
var oldNTE = dispatch.NTEAmount ?? 0m;
|
||||||
|
|
||||||
dispatch.NTEAmount = req.RequestedNTE;
|
// Approval raises the NTE by the request's amount. A vendor-portal request
|
||||||
|
// stores its requested NTE total and ends exactly there; a work-order request
|
||||||
|
// stores the increase, so it is added to the dispatch's current NTE.
|
||||||
|
dispatch.NTEAmount = req.createdby == null
|
||||||
|
? req.RequestedNTE
|
||||||
|
: oldNTE + UpliftAmount.Of(req);
|
||||||
dispatch.LastModificationTime = now;
|
dispatch.LastModificationTime = now;
|
||||||
|
|
||||||
req.Status = UpliftStatus.Approved;
|
req.Status = UpliftStatus.Approved;
|
||||||
|
|
@ -166,13 +172,13 @@ namespace SeaHaven.Services.Implementation
|
||||||
req.DecisionNote = string.IsNullOrWhiteSpace(note) ? null : note!.Trim();
|
req.DecisionNote = string.IsNullOrWhiteSpace(note) ? null : note!.Trim();
|
||||||
req.LastModificationTime = now;
|
req.LastModificationTime = now;
|
||||||
|
|
||||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
await StageDecisionAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
|
||||||
{
|
{
|
||||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
WorkOrderId = workOrderId,
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
FieldName = $"Dispatch {dispatch.DispatchNumber} NTE",
|
FieldName = $"Dispatch {dispatch.DispatchNumber} NTE",
|
||||||
OldValue = $"${oldNTE:F2}",
|
OldValue = $"${oldNTE:F2}",
|
||||||
NewValue = $"${req.RequestedNTE:F2}",
|
NewValue = $"${dispatch.NTEAmount:F2}",
|
||||||
Action = "uplift_approved",
|
Action = "uplift_approved",
|
||||||
CreatedAt = now
|
CreatedAt = now
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
@ -252,9 +258,9 @@ namespace SeaHaven.Services.Implementation
|
||||||
req.DecisionNote = note!.Trim();
|
req.DecisionNote = note!.Trim();
|
||||||
req.LastModificationTime = now;
|
req.LastModificationTime = now;
|
||||||
|
|
||||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
await StageDecisionAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
|
||||||
{
|
{
|
||||||
WorkOrderId = dispatch?.WorkOrderId ?? 0,
|
WorkOrderId = workOrderId,
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
|
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
|
||||||
OldValue = previous,
|
OldValue = previous,
|
||||||
|
|
@ -290,9 +296,9 @@ namespace SeaHaven.Services.Implementation
|
||||||
req.DecisionNote = note.Trim();
|
req.DecisionNote = note.Trim();
|
||||||
req.LastModificationTime = now;
|
req.LastModificationTime = now;
|
||||||
|
|
||||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
await StageDecisionAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
|
||||||
{
|
{
|
||||||
WorkOrderId = dispatch?.WorkOrderId ?? 0,
|
WorkOrderId = workOrderId,
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
|
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
|
||||||
OldValue = previous,
|
OldValue = previous,
|
||||||
|
|
@ -307,6 +313,19 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
public bool CanApprove(ClaimsPrincipal user, int tier) => UserCanApprove(user, tier);
|
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
|
// 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
|
// service resolves the document by the request's own linkage (no client-supplied
|
||||||
// document id or vendor/public path). Only UpliftEvidence documents are exposed;
|
// document id or vendor/public path). Only UpliftEvidence documents are exposed;
|
||||||
|
|
@ -335,8 +354,12 @@ namespace SeaHaven.Services.Implementation
|
||||||
return UpliftEvidenceDownloadResultDTO.Ok(content, evidence.ContentType ?? "application/octet-stream", evidence.OriginalFileName ?? "evidence");
|
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)
|
private bool UserCanApprove(ClaimsPrincipal user, int requiredTier)
|
||||||
{
|
{
|
||||||
|
if (user.IsInRole("Admin")) return true;
|
||||||
|
|
||||||
var roles = RolesForTier(requiredTier);
|
var roles = RolesForTier(requiredTier);
|
||||||
foreach (var r in roles)
|
foreach (var r in roles)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ 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.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
namespace SeaHaven.Services.Implementation
|
namespace SeaHaven.Services.Implementation
|
||||||
|
|
@ -12,9 +14,36 @@ namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
private static readonly HashSet<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly HashSet<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
"application/pdf", "image/jpeg", "image/jpg", "image/png"
|
"application/pdf", "image/jpeg", "image/jpg", "image/png", "image/heic",
|
||||||
|
"video/mp4", "video/quicktime"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The browser's File.type is unreliable on mobile (empty or application/octet-stream),
|
||||||
|
// so an extension pairing against the same allowlist resolves the real content type.
|
||||||
|
private static string? ResolveUploadContentType(IFormFile file)
|
||||||
|
{
|
||||||
|
var declaredType = (file.ContentType ?? string.Empty).Trim();
|
||||||
|
var extension = Path.GetExtension(file.FileName ?? string.Empty);
|
||||||
|
// iOS transcodes a picked HEIC photo to JPEG (named .jpg) but can keep image/heic.
|
||||||
|
var isTranscodedHeic = declaredType.Equals("image/heic", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !extension.Equals(".heic", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (AllowedContentTypes.Contains(declaredType) && !isTranscodedHeic)
|
||||||
|
return declaredType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "image/jpeg"
|
||||||
|
: declaredType;
|
||||||
|
|
||||||
|
return extension.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
".pdf" => "application/pdf",
|
||||||
|
".jpg" or ".jpeg" => "image/jpeg",
|
||||||
|
".png" => "image/png",
|
||||||
|
".heic" => "image/heic",
|
||||||
|
".mp4" => "video/mp4",
|
||||||
|
".mov" => "video/quicktime",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private const int MaxRefusalReasonLength = 500;
|
private const int MaxRefusalReasonLength = 500;
|
||||||
|
|
||||||
private readonly IVendorPortalTokenService _tokens;
|
private readonly IVendorPortalTokenService _tokens;
|
||||||
|
|
@ -106,6 +135,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
Status = UpliftStatus.ToCanonical(u.Status),
|
Status = UpliftStatus.ToCanonical(u.Status),
|
||||||
RequiredTier = u.RequiredTier,
|
RequiredTier = u.RequiredTier,
|
||||||
RequestedByVendorName = u.RequestedByVendorName,
|
RequestedByVendorName = u.RequestedByVendorName,
|
||||||
|
RaisedByVendor = u.RaisedByVendor,
|
||||||
RequestedAt = u.CreatedDate,
|
RequestedAt = u.CreatedDate,
|
||||||
DecidedAt = u.DecidedAt,
|
DecidedAt = u.DecidedAt,
|
||||||
DecisionNote = u.DecisionNote,
|
DecisionNote = u.DecisionNote,
|
||||||
|
|
@ -178,8 +208,37 @@ namespace SeaHaven.Services.Implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PortalMediaCountsDTO? mediaCounts = null;
|
||||||
|
if (dispatch.WorkOrderId is int workOrderId)
|
||||||
|
{
|
||||||
|
// Same basis as the upload check: a new completion version replaces the
|
||||||
|
// dispatch's latest document, so that one is not counted for it.
|
||||||
|
var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync(
|
||||||
|
workOrderId, cancellationToken);
|
||||||
|
var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync(
|
||||||
|
workOrderId, null, cancellationToken);
|
||||||
|
var latest = await _documentData.GetLatestForDispatchAsync(id, cancellationToken);
|
||||||
|
var completionTypes = latest == null
|
||||||
|
? vendorTypes
|
||||||
|
: await _documentData.ListActiveContentTypesForWorkOrderAsync(
|
||||||
|
workOrderId, latest.Id, cancellationToken);
|
||||||
|
var (photos, videos) = WorkOrderMediaContract.CountKinds(attachmentUrls, vendorTypes);
|
||||||
|
var (completionPhotos, completionVideos) =
|
||||||
|
WorkOrderMediaContract.CountKinds(attachmentUrls, completionTypes);
|
||||||
|
mediaCounts = new PortalMediaCountsDTO
|
||||||
|
{
|
||||||
|
MaxPhotos = WorkOrderMediaContract.MaxPhotosPerWorkOrder,
|
||||||
|
MaxVideos = WorkOrderMediaContract.MaxVideosPerWorkOrder,
|
||||||
|
Photos = photos,
|
||||||
|
Videos = videos,
|
||||||
|
CompletionPhotos = completionPhotos,
|
||||||
|
CompletionVideos = completionVideos
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return new VendorDispatchDetailDTO
|
return new VendorDispatchDetailDTO
|
||||||
{
|
{
|
||||||
|
MediaCounts = mediaCounts,
|
||||||
Id = dispatch.Id,
|
Id = dispatch.Id,
|
||||||
DispatchNumber = dispatch.DispatchNumber,
|
DispatchNumber = dispatch.DispatchNumber,
|
||||||
PONumber = dispatch.PONumber,
|
PONumber = dispatch.PONumber,
|
||||||
|
|
@ -651,7 +710,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).
|
||||||
|
|
@ -686,7 +752,9 @@ namespace SeaHaven.Services.Implementation
|
||||||
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
|
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
|
||||||
|
|
||||||
var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken);
|
var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken);
|
||||||
if (req == null) throw new KeyNotFoundException("Uplift request not found");
|
// A vendor withdraws only requests it raised; requests raised from the work order
|
||||||
|
// (createdby set) are read-only in the portal.
|
||||||
|
if (req == null || req.createdby != null) throw new KeyNotFoundException("Uplift request not found");
|
||||||
|
|
||||||
if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Withdrawn))
|
if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Withdrawn))
|
||||||
{
|
{
|
||||||
|
|
@ -728,7 +796,9 @@ namespace SeaHaven.Services.Implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken);
|
var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken);
|
||||||
if (req == null) throw new KeyNotFoundException("Uplift request not found");
|
// A vendor revises only requests it raised. Work-order requests (createdby set)
|
||||||
|
// store the increase, not a total, so a vendor revise would corrupt their amount.
|
||||||
|
if (req == null || req.createdby != null) throw new KeyNotFoundException("Uplift request not found");
|
||||||
|
|
||||||
if (UpliftStatus.ToCanonical(req.Status) != UpliftStatus.ChangesRequested)
|
if (UpliftStatus.ToCanonical(req.Status) != UpliftStatus.ChangesRequested)
|
||||||
{
|
{
|
||||||
|
|
@ -820,15 +890,31 @@ namespace SeaHaven.Services.Implementation
|
||||||
throw new InvalidOperationException("A completion document file is required.");
|
throw new InvalidOperationException("A completion document file is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.Length > _documentOptions.MaxSizeBytes)
|
// Media contract: photos up to 10 MB (JPEG/PNG/HEIC), videos up to 100 MB
|
||||||
|
// (MP4/MOV). Documents keep the configured VendorDocuments cap.
|
||||||
|
var contentType = ResolveUploadContentType(file);
|
||||||
|
if (contentType == null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("The uploaded file exceeds the maximum allowed size.");
|
throw new InvalidOperationException("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are accepted.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var contentType = (file.ContentType ?? string.Empty).Trim();
|
// Classify by the resolved type the signature check validates, never by the file name.
|
||||||
if (!AllowedContentTypes.Contains(contentType))
|
var kind = WorkOrderMediaContract.ResolveKind(contentType, fileName: null);
|
||||||
|
if (kind == WorkOrderMediaContract.UploadKind.Photo
|
||||||
|
&& file.Length > WorkOrderMediaContract.MaxPhotoBytes)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Only PDF, JPG, and PNG completion documents are accepted.");
|
throw new InvalidOperationException("Photos must be 10 MB or smaller.");
|
||||||
|
}
|
||||||
|
if (kind == WorkOrderMediaContract.UploadKind.Video
|
||||||
|
&& file.Length > WorkOrderMediaContract.MaxVideoBytes)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Videos must be 100 MB or smaller.");
|
||||||
|
}
|
||||||
|
if (kind != WorkOrderMediaContract.UploadKind.Photo
|
||||||
|
&& kind != WorkOrderMediaContract.UploadKind.Video
|
||||||
|
&& file.Length > _documentOptions.MaxSizeBytes)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The uploaded file exceeds the maximum allowed size.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var resolvedPurpose = string.IsNullOrWhiteSpace(purpose)
|
var resolvedPurpose = string.IsNullOrWhiteSpace(purpose)
|
||||||
|
|
@ -851,11 +937,21 @@ namespace SeaHaven.Services.Implementation
|
||||||
await file.CopyToAsync(buffer, cancellationToken);
|
await file.CopyToAsync(buffer, cancellationToken);
|
||||||
var bytes = buffer.ToArray();
|
var bytes = buffer.ToArray();
|
||||||
|
|
||||||
if (!MatchesSignature(contentType, bytes))
|
if (!WorkOrderMediaFileRules.MatchesSignature(contentType, bytes))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("The uploaded file signature does not match its declared content type.");
|
throw new InvalidOperationException("The uploaded file signature does not match its declared content type.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (kind == WorkOrderMediaContract.UploadKind.Video)
|
||||||
|
{
|
||||||
|
using var content = new MemoryStream(bytes, writable: false);
|
||||||
|
var durationMessage = WorkOrderMediaContract.ValidateVideoDuration(content);
|
||||||
|
if (durationMessage != null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(durationMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(dispatchId, session.Id, cancellationToken);
|
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(dispatchId, session.Id, cancellationToken);
|
||||||
if (dispatch == null)
|
if (dispatch == null)
|
||||||
{
|
{
|
||||||
|
|
@ -882,50 +978,80 @@ namespace SeaHaven.Services.Implementation
|
||||||
"The completion document could not be replaced.");
|
"The completion document could not be replaced.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var version = (latest?.Version ?? 0) + 1;
|
var version = (latest?.Version ?? 0) + 1;
|
||||||
var storedFileName = $"{dispatchId}_{version}_{Guid.NewGuid():N}{GetSafeExtension(file.FileName)}";
|
var storedFileName = $"{dispatchId}_{version}_{Guid.NewGuid():N}{GetSafeExtension(file.FileName)}";
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var document = new VendorCompletionDocument
|
|
||||||
{
|
|
||||||
VendorId = session.Id,
|
|
||||||
DispatchId = dispatchId,
|
|
||||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
|
||||||
OriginalFileName = Path.GetFileName(file.FileName),
|
|
||||||
StoredFileName = storedFileName,
|
|
||||||
ContentType = contentType,
|
|
||||||
SizeBytes = bytes.Length,
|
|
||||||
ScanStatus = "Pending",
|
|
||||||
ReviewStatus = "Processing",
|
|
||||||
Version = version,
|
|
||||||
ReplacesDocumentId = replacedDocument?.Id,
|
|
||||||
Purpose = resolvedPurpose,
|
|
||||||
CreatedDate = now
|
|
||||||
};
|
|
||||||
|
|
||||||
await _documentData.AddAsync(document, cancellationToken);
|
|
||||||
|
|
||||||
var isUpliftEvidence = resolvedPurpose == VendorDocumentPurpose.UpliftEvidence;
|
var isUpliftEvidence = resolvedPurpose == VendorDocumentPurpose.UpliftEvidence;
|
||||||
var fieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document";
|
|
||||||
|
|
||||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
async Task<VendorCompletionDocument> PersistAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
// The 10-photo / 3-video limit is per work order across the dispatcher and
|
||||||
DispatchId = dispatchId,
|
// vendor surfaces. A new completion version replaces its predecessor, so that one
|
||||||
FieldName = fieldName,
|
// does not count against the upload that supersedes it.
|
||||||
OldValue = isUpliftEvidence || replacedDocument == null
|
if (dispatch.WorkOrderId is int workOrderId)
|
||||||
? null
|
{
|
||||||
: $"v{replacedDocument.Version}",
|
var excluded = resolvedPurpose == VendorDocumentPurpose.Completion
|
||||||
NewValue = isUpliftEvidence
|
? replacedDocument?.Id
|
||||||
? $"evidence {document.OriginalFileName}"
|
: null;
|
||||||
: $"v{version}",
|
var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync(
|
||||||
Action = isUpliftEvidence
|
workOrderId, excluded, ct);
|
||||||
? "vendor_uplift_evidence_uploaded"
|
var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync(
|
||||||
: "vendor_completion_document_uploaded",
|
workOrderId, ct);
|
||||||
ActorType = "vendor",
|
var countMessage = WorkOrderMediaContract.ValidateCount(kind, attachmentUrls, vendorTypes);
|
||||||
CreatedAt = now
|
if (countMessage != null)
|
||||||
}, cancellationToken);
|
{
|
||||||
await _documentData.SaveChangesAsync(cancellationToken);
|
throw new InvalidOperationException(countMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var created = new VendorCompletionDocument
|
||||||
|
{
|
||||||
|
VendorId = session.Id,
|
||||||
|
DispatchId = dispatchId,
|
||||||
|
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||||
|
OriginalFileName = Path.GetFileName(file.FileName),
|
||||||
|
StoredFileName = storedFileName,
|
||||||
|
ContentType = contentType,
|
||||||
|
SizeBytes = bytes.Length,
|
||||||
|
ScanStatus = "Pending",
|
||||||
|
ReviewStatus = "Processing",
|
||||||
|
Version = version,
|
||||||
|
ReplacesDocumentId = replacedDocument?.Id,
|
||||||
|
Purpose = resolvedPurpose,
|
||||||
|
CreatedDate = now
|
||||||
|
};
|
||||||
|
|
||||||
|
await _documentData.AddAsync(created, ct);
|
||||||
|
|
||||||
|
var fieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document";
|
||||||
|
|
||||||
|
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||||
|
{
|
||||||
|
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||||
|
DispatchId = dispatchId,
|
||||||
|
FieldName = fieldName,
|
||||||
|
OldValue = isUpliftEvidence || replacedDocument == null
|
||||||
|
? null
|
||||||
|
: $"v{replacedDocument.Version}",
|
||||||
|
NewValue = isUpliftEvidence
|
||||||
|
? $"evidence {created.OriginalFileName}"
|
||||||
|
: $"v{version}",
|
||||||
|
Action = isUpliftEvidence
|
||||||
|
? "vendor_uplift_evidence_uploaded"
|
||||||
|
: "vendor_completion_document_uploaded",
|
||||||
|
ActorType = "vendor",
|
||||||
|
CreatedAt = now
|
||||||
|
}, ct);
|
||||||
|
await _documentData.SaveChangesAsync(ct);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The count and the insert run under the per-work-order mutation lock the dispatcher
|
||||||
|
// media upload also takes, so concurrent uploads cannot both claim the last slot.
|
||||||
|
var document = dispatch.WorkOrderId is int lockedWorkOrderId
|
||||||
|
? await _upliftData.ExecuteWorkOrderMutationAsync(lockedWorkOrderId, PersistAsync, cancellationToken)
|
||||||
|
: await PersistAsync(cancellationToken);
|
||||||
|
|
||||||
using var stored = new MemoryStream(bytes);
|
using var stored = new MemoryStream(bytes);
|
||||||
await _documentStorage.SaveAsync(session.Id, dispatchId, storedFileName, stored, cancellationToken);
|
await _documentStorage.SaveAsync(session.Id, dispatchId, storedFileName, stored, cancellationToken);
|
||||||
|
|
@ -984,35 +1110,6 @@ namespace SeaHaven.Services.Implementation
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool MatchesSignature(string contentType, byte[] bytes)
|
|
||||||
{
|
|
||||||
if (bytes.Length == 0)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return bytes.Length >= 4
|
|
||||||
&& bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46; // %PDF
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return bytes.Length >= 8
|
|
||||||
&& bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47
|
|
||||||
&& bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetSafeExtension(string fileName)
|
private static string GetSafeExtension(string fileName)
|
||||||
{
|
{
|
||||||
var extension = Path.GetExtension(fileName);
|
var extension = Path.GetExtension(fileName);
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,11 @@ namespace SeaHaven.Services.Implementation
|
||||||
sortBy,
|
sortBy,
|
||||||
query.SortDir ?? "asc",
|
query.SortDir ?? "asc",
|
||||||
accountId,
|
accountId,
|
||||||
query.IncludeDateless);
|
WorkOrderSearchDateRangeResolver.ResolveIncludeDateless(
|
||||||
|
query.IncludeDateless,
|
||||||
|
query.DatePreset,
|
||||||
|
query.DateFrom,
|
||||||
|
query.DateTo));
|
||||||
|
|
||||||
var result = await _searchDataService.SearchAsync(dataQuery);
|
var result = await _searchDataService.SearchAsync(dataQuery);
|
||||||
var utcNow = DateTime.UtcNow;
|
var utcNow = DateTime.UtcNow;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,17 +16,20 @@ namespace SeaHaven.Services.Implementation
|
||||||
private readonly IWorkOrderDetailDataService _detailData;
|
private readonly IWorkOrderDetailDataService _detailData;
|
||||||
private readonly IWorkOrderAuditService _auditService;
|
private readonly IWorkOrderAuditService _auditService;
|
||||||
private readonly IFileStoragePort _fileStorage;
|
private readonly IFileStoragePort _fileStorage;
|
||||||
|
private readonly IUpliftDataService _upliftData;
|
||||||
|
|
||||||
public WorkOrderMediaService(
|
public WorkOrderMediaService(
|
||||||
IWorkOrderMediaDataService mediaData,
|
IWorkOrderMediaDataService mediaData,
|
||||||
IWorkOrderDetailDataService detailData,
|
IWorkOrderDetailDataService detailData,
|
||||||
IWorkOrderAuditService auditService,
|
IWorkOrderAuditService auditService,
|
||||||
IFileStoragePort fileStorage)
|
IFileStoragePort fileStorage,
|
||||||
|
IUpliftDataService upliftData)
|
||||||
{
|
{
|
||||||
_mediaData = mediaData;
|
_mediaData = mediaData;
|
||||||
_detailData = detailData;
|
_detailData = detailData;
|
||||||
_auditService = auditService;
|
_auditService = auditService;
|
||||||
_fileStorage = fileStorage;
|
_fileStorage = fileStorage;
|
||||||
|
_upliftData = upliftData;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(
|
public async Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(
|
||||||
|
|
@ -153,23 +156,34 @@ namespace SeaHaven.Services.Implementation
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
var attachment = new WorkOrderAttachments
|
// Photo/video caps are a work-order aggregate shared with the vendor portal
|
||||||
{
|
// upload, so the count and the insert run under the per-work-order mutation lock.
|
||||||
WorkorderId = workOrderId,
|
var attachment = await _upliftData.ExecuteWorkOrderMutationAsync(
|
||||||
Attachments = fileUrl,
|
|
||||||
Category = category.HasValue ? resolvedCategory : null,
|
|
||||||
CreatedDate = DateTime.UtcNow,
|
|
||||||
createdby = actorId
|
|
||||||
};
|
|
||||||
|
|
||||||
_mediaData.TrackAttachment(attachment);
|
|
||||||
await _auditService.StageFieldChangedAsync(
|
|
||||||
workOrderId,
|
workOrderId,
|
||||||
"MediaCategory",
|
async ct =>
|
||||||
null,
|
{
|
||||||
FormatMediaAuditValue(null, (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString()),
|
await EnsureWithinMediaCountsAsync(workOrderId, fileUrl, ct);
|
||||||
actorId);
|
|
||||||
await SaveMediaAsync(cancellationToken);
|
var created = new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = workOrderId,
|
||||||
|
Attachments = fileUrl,
|
||||||
|
Category = category.HasValue ? resolvedCategory : null,
|
||||||
|
CreatedDate = DateTime.UtcNow,
|
||||||
|
createdby = actorId
|
||||||
|
};
|
||||||
|
|
||||||
|
_mediaData.TrackAttachment(created);
|
||||||
|
await _auditService.StageFieldChangedAsync(
|
||||||
|
workOrderId,
|
||||||
|
"MediaCategory",
|
||||||
|
null,
|
||||||
|
FormatMediaAuditValue(null, (created.Category ?? WorkOrderMediaCategory.Extra).ToString()),
|
||||||
|
actorId);
|
||||||
|
await SaveMediaAsync(ct);
|
||||||
|
return created;
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
return new WorkOrderMediaFileDto
|
return new WorkOrderMediaFileDto
|
||||||
{
|
{
|
||||||
|
|
@ -348,6 +362,29 @@ namespace SeaHaven.Services.Implementation
|
||||||
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
|
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Media contract: at most 10 photos and 3 videos per work order, counted over the
|
||||||
|
/// stored attachment rows plus the work order's current vendor-portal documents. Legacy
|
||||||
|
/// Before/After column slots replace in place and are not counted. Documents have no
|
||||||
|
/// per-work-order count limit.
|
||||||
|
/// </summary>
|
||||||
|
private async Task EnsureWithinMediaCountsAsync(
|
||||||
|
int workOrderId,
|
||||||
|
string fileUrl,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var kind = WorkOrderMediaContract.ResolveKind(null, fileUrl);
|
||||||
|
if (kind != WorkOrderMediaContract.UploadKind.Photo
|
||||||
|
&& kind != WorkOrderMediaContract.UploadKind.Video)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var urls = await _mediaData.ListActiveAttachmentUrlsAsync(workOrderId, cancellationToken);
|
||||||
|
var vendorTypes = await _mediaData.ListActiveVendorMediaContentTypesAsync(workOrderId, cancellationToken);
|
||||||
|
var countMessage = WorkOrderMediaContract.ValidateCount(kind, urls, vendorTypes);
|
||||||
|
if (countMessage != null)
|
||||||
|
throw new WorkOrderBoardValidationException("MediaCountExceeded", countMessage);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Account filter for data queries. Null means org-wide (skip ApplyAccountScope).
|
/// Account filter for data queries. Null means org-wide (skip ApplyAccountScope).
|
||||||
/// Call only after EnsureCan* has verified scope is not Missing.
|
/// Call only after EnsureCan* has verified scope is not Missing.
|
||||||
|
|
@ -432,6 +469,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
".jpg" or ".jpeg" => "image/jpeg",
|
".jpg" or ".jpeg" => "image/jpeg",
|
||||||
".png" => "image/png",
|
".png" => "image/png",
|
||||||
|
".heic" => "image/heic",
|
||||||
".mp4" => "video/mp4",
|
".mp4" => "video/mp4",
|
||||||
".mov" => "video/quicktime",
|
".mov" => "video/quicktime",
|
||||||
".pdf" => "application/pdf",
|
".pdf" => "application/pdf",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.Configuration;
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.Constants;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Exceptions;
|
using SeaHaven.Services.Exceptions;
|
||||||
using SeaHaven.Services.Helpers;
|
using SeaHaven.Services.Helpers;
|
||||||
|
|
@ -19,6 +20,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
private readonly IWorkOrderDetailDataService _detailData;
|
private readonly IWorkOrderDetailDataService _detailData;
|
||||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||||
private readonly IUserDataService _userData;
|
private readonly IUserDataService _userData;
|
||||||
|
private readonly ITeamPermissionOverrideDataService _permissionOverrideData;
|
||||||
|
private readonly ITeamPermissionPolicy _permissionPolicy;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly ApprovalsOptions _approvalsOptions;
|
private readonly ApprovalsOptions _approvalsOptions;
|
||||||
|
|
||||||
|
|
@ -28,6 +31,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
IWorkOrderDetailDataService detailData,
|
IWorkOrderDetailDataService detailData,
|
||||||
IWorkOrderAccountResolver accountResolver,
|
IWorkOrderAccountResolver accountResolver,
|
||||||
IUserDataService userData,
|
IUserDataService userData,
|
||||||
|
ITeamPermissionOverrideDataService permissionOverrideData,
|
||||||
|
ITeamPermissionPolicy permissionPolicy,
|
||||||
TimeProvider timeProvider,
|
TimeProvider timeProvider,
|
||||||
IOptions<ApprovalsOptions> approvalsOptions)
|
IOptions<ApprovalsOptions> approvalsOptions)
|
||||||
{
|
{
|
||||||
|
|
@ -36,6 +41,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
_detailData = detailData;
|
_detailData = detailData;
|
||||||
_accountResolver = accountResolver;
|
_accountResolver = accountResolver;
|
||||||
_userData = userData;
|
_userData = userData;
|
||||||
|
_permissionOverrideData = permissionOverrideData;
|
||||||
|
_permissionPolicy = permissionPolicy;
|
||||||
_timeProvider = timeProvider;
|
_timeProvider = timeProvider;
|
||||||
_approvalsOptions = approvalsOptions.Value;
|
_approvalsOptions = approvalsOptions.Value;
|
||||||
}
|
}
|
||||||
|
|
@ -61,6 +68,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken))
|
if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
await EnsureCanRequestUpliftAsync(user, cancellationToken);
|
||||||
|
|
||||||
if (request.Amount <= 0)
|
if (request.Amount <= 0)
|
||||||
throw new InvalidOperationException("Uplift amount must be greater than zero");
|
throw new InvalidOperationException("Uplift amount must be greater than zero");
|
||||||
|
|
||||||
|
|
@ -69,17 +78,49 @@ namespace SeaHaven.Services.Implementation
|
||||||
var notes = request.Notes?.Trim() ?? "";
|
var notes = request.Notes?.Trim() ?? "";
|
||||||
var accountFilter = _accountResolver.ResolveAccountFilter(user);
|
var accountFilter = _accountResolver.ResolveAccountFilter(user);
|
||||||
|
|
||||||
return await _upliftData.ExecuteWorkOrderMutationAsync(
|
try
|
||||||
workOrderId,
|
{
|
||||||
ct => CreateLockedAsync(
|
return await _upliftData.ExecuteWorkOrderMutationAsync(
|
||||||
workOrderId,
|
workOrderId,
|
||||||
request.Amount,
|
ct => CreateLockedAsync(
|
||||||
notes,
|
workOrderId,
|
||||||
userId,
|
request.Amount,
|
||||||
requesterName,
|
notes,
|
||||||
accountFilter,
|
userId,
|
||||||
ct),
|
requesterName,
|
||||||
cancellationToken);
|
accountFilter,
|
||||||
|
ct),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException)
|
||||||
|
{
|
||||||
|
throw new UpliftConflictException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureCanRequestUpliftAsync(
|
||||||
|
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(
|
private async Task<WorkOrderUpliftDto?> CreateLockedAsync(
|
||||||
|
|
@ -104,24 +145,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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
336
SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs
Normal file
336
SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHaven.DataServices.Helpers;
|
||||||
|
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 SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
// An uplift's amount is the NTE increase being requested. Work-order requests store
|
||||||
|
// that increase in RequestedNTE; vendor-portal requests store the requested NTE total.
|
||||||
|
// These tests walk each path through the approvals queue, approve and revoke, and the
|
||||||
|
// queue-wide and per-work-order exposure totals.
|
||||||
|
public sealed class UpliftAmountPerCreationPathTests
|
||||||
|
{
|
||||||
|
private const string DispatcherId = "dispatcher-1";
|
||||||
|
private const int WorkOrderId = 1;
|
||||||
|
private const int PrimaryDispatchId = 10;
|
||||||
|
private const int SecondDispatchId = 11;
|
||||||
|
|
||||||
|
private static ApplicationDbContext CreateContext() =>
|
||||||
|
new(new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options);
|
||||||
|
|
||||||
|
private static ApprovalsOptions NewOptions() => new()
|
||||||
|
{
|
||||||
|
UpliftTier1MaxUsd = 2500m,
|
||||||
|
Tier1Roles = new[] { "Approver" },
|
||||||
|
Tier2Roles = new[] { "Manager" },
|
||||||
|
};
|
||||||
|
|
||||||
|
private static WorkOrderUpliftService NewWorkOrderUpliftService(ApplicationDbContext context) =>
|
||||||
|
new(new UpliftDataService(context),
|
||||||
|
new DispatchDataService(context),
|
||||||
|
new WorkOrderDetailDataService(context),
|
||||||
|
WorkOrderAccountTestHelpers.Resolver(context),
|
||||||
|
new UserDataService(context),
|
||||||
|
new TeamPermissionOverrideDataService(context),
|
||||||
|
new TeamPermissionPolicy(),
|
||||||
|
TimeProvider.System,
|
||||||
|
Options.Create(NewOptions()));
|
||||||
|
|
||||||
|
private static UpliftService NewQueueService(ApplicationDbContext context) =>
|
||||||
|
new(new UpliftDataService(context),
|
||||||
|
new DispatchDataService(context),
|
||||||
|
new NoDocumentStorage(),
|
||||||
|
TimeProvider.System,
|
||||||
|
Options.Create(NewOptions()));
|
||||||
|
|
||||||
|
private static ClaimsPrincipal Admin(string userId = DispatcherId) =>
|
||||||
|
WorkOrderAccountTestHelpers.OrgWideAdmin(userId);
|
||||||
|
|
||||||
|
private static Dispatch DispatchOf(ApplicationDbContext context, int id) =>
|
||||||
|
context.Dispatches.Single(d => d.Id == id);
|
||||||
|
|
||||||
|
// A work order with a 600 NTE on its primary dispatch, and a second dispatch on the
|
||||||
|
// same work order, also at 600.
|
||||||
|
private static async Task SeedWorkOrderAsync(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
|
||||||
|
context.Users.Add(new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = DispatcherId,
|
||||||
|
UserName = DispatcherId,
|
||||||
|
FirstName = "Alex",
|
||||||
|
LastName = "Dispatcher",
|
||||||
|
});
|
||||||
|
var role = new IdentityRole("Admin");
|
||||||
|
context.Roles.Add(role);
|
||||||
|
context.UserRoles.Add(new IdentityUserRole<string> { UserId = DispatcherId, RoleId = role.Id });
|
||||||
|
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
|
||||||
|
context.Dispatches.AddRange(
|
||||||
|
new Dispatch
|
||||||
|
{
|
||||||
|
Id = PrimaryDispatchId,
|
||||||
|
VendorId = 1,
|
||||||
|
WorkOrderId = WorkOrderId,
|
||||||
|
NTEAmount = 600m,
|
||||||
|
DispatchNumber = "DIS-10",
|
||||||
|
Status = "Completed",
|
||||||
|
},
|
||||||
|
new Dispatch
|
||||||
|
{
|
||||||
|
Id = SecondDispatchId,
|
||||||
|
VendorId = 1,
|
||||||
|
WorkOrderId = WorkOrderId,
|
||||||
|
NTEAmount = 600m,
|
||||||
|
DispatchNumber = "DIS-11",
|
||||||
|
Status = "Completed",
|
||||||
|
});
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = WorkOrderId,
|
||||||
|
InternalWONumber = "10000000001",
|
||||||
|
PrimaryDispatchId = PrimaryDispatchId,
|
||||||
|
AccountId = 1,
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uses up the work order's auto-approval allowance, so a new work-order request
|
||||||
|
// waits for approval instead of auto-approving.
|
||||||
|
private static async Task ConsumeAutoApprovalAllowanceAsync(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||||
|
{
|
||||||
|
DispatchId = SecondDispatchId,
|
||||||
|
CurrentNTE = 100m,
|
||||||
|
RequestedNTE = 500m,
|
||||||
|
Status = "NoApprovalRequired",
|
||||||
|
RequiredTier = 0,
|
||||||
|
NotificationStatus = "Sent",
|
||||||
|
createdby = DispatcherId,
|
||||||
|
CreatedDate = new DateTime(2026, 1, 2),
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> CreatePendingWorkOrderRequestAsync(ApplicationDbContext context, decimal amount)
|
||||||
|
{
|
||||||
|
await ConsumeAutoApprovalAllowanceAsync(context);
|
||||||
|
var created = await NewWorkOrderUpliftService(context).CreateAsync(
|
||||||
|
WorkOrderId,
|
||||||
|
new CreateWorkOrderUpliftRequestDto { Amount = amount, Notes = "Extra parts" },
|
||||||
|
Admin(),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.Equal("pending", created!.Status);
|
||||||
|
return created.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stored exactly as the vendor portal writes it: no identity user, and RequestedNTE
|
||||||
|
// is the requested new NTE total.
|
||||||
|
private static async Task<int> SeedVendorRequestAsync(
|
||||||
|
ApplicationDbContext context, int dispatchId, decimal currentNte, decimal requestedTotal)
|
||||||
|
{
|
||||||
|
var request = new DispatchUpliftRequest
|
||||||
|
{
|
||||||
|
DispatchId = dispatchId,
|
||||||
|
CurrentNTE = currentNte,
|
||||||
|
RequestedNTE = requestedTotal,
|
||||||
|
Status = "Pending",
|
||||||
|
RequiredTier = 1,
|
||||||
|
RequestedByVendorName = "Acme HVAC",
|
||||||
|
NotificationStatus = "Sent",
|
||||||
|
CreatedDate = new DateTime(2026, 3, 1),
|
||||||
|
};
|
||||||
|
context.DispatchUpliftRequests.Add(request);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
return request.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<WorkOrderUpliftDto?> RevokeAsync(ApplicationDbContext context, int upliftId) =>
|
||||||
|
NewWorkOrderUpliftService(context).RevokeAsync(
|
||||||
|
WorkOrderId,
|
||||||
|
upliftId,
|
||||||
|
new RevokeWorkOrderUpliftRequestDto { Reason = "Scope reduced" },
|
||||||
|
Admin(),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WorkOrderRequest_QueueShowsTheRequestedIncrease()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
await SeedWorkOrderAsync(context);
|
||||||
|
var upliftId = await CreatePendingWorkOrderRequestAsync(context, 90m);
|
||||||
|
|
||||||
|
var pending = await NewQueueService(context)
|
||||||
|
.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(90m, Assert.Single(pending.Items, i => i.Id == upliftId).Delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WorkOrderRequest_ApproveAddsIncreaseToNte_RevokeRestoresNte()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
await SeedWorkOrderAsync(context);
|
||||||
|
var upliftId = await CreatePendingWorkOrderRequestAsync(context, 90m);
|
||||||
|
var queue = NewQueueService(context);
|
||||||
|
|
||||||
|
var approved = await queue.ApproveAsync(Admin(), upliftId, null, CancellationToken.None);
|
||||||
|
Assert.Equal(690m, approved.NTEAmount);
|
||||||
|
Assert.Equal(690m, DispatchOf(context, PrimaryDispatchId).NTEAmount);
|
||||||
|
|
||||||
|
var revoked = await RevokeAsync(context, upliftId);
|
||||||
|
Assert.Equal("revoked", revoked!.Status);
|
||||||
|
Assert.Equal(600m, DispatchOf(context, PrimaryDispatchId).NTEAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task VendorRequest_QueueShowsIncrease_ApproveEndsAtRequestedTotal_RevokeRestoresNte()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
await SeedWorkOrderAsync(context);
|
||||||
|
var upliftId = await SeedVendorRequestAsync(context, PrimaryDispatchId, currentNte: 600m, requestedTotal: 900m);
|
||||||
|
var queue = NewQueueService(context);
|
||||||
|
|
||||||
|
var pending = await queue.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None);
|
||||||
|
Assert.Equal(300m, Assert.Single(pending.Items).Delta);
|
||||||
|
|
||||||
|
var approved = await queue.ApproveAsync(Admin(), upliftId, null, CancellationToken.None);
|
||||||
|
Assert.Equal(900m, approved.NTEAmount);
|
||||||
|
Assert.Equal(900m, DispatchOf(context, PrimaryDispatchId).NTEAmount);
|
||||||
|
|
||||||
|
var revoked = await RevokeAsync(context, upliftId);
|
||||||
|
Assert.Equal("revoked", revoked!.Status);
|
||||||
|
Assert.Equal(600m, DispatchOf(context, PrimaryDispatchId).NTEAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PendingExposureAndApprovedOnWorkOrder_SumEachRequestsIncrease()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
await SeedWorkOrderAsync(context);
|
||||||
|
var workOrderRequestId = await CreatePendingWorkOrderRequestAsync(context, 90m);
|
||||||
|
var vendorRequestId = await SeedVendorRequestAsync(context, SecondDispatchId, currentNte: 600m, requestedTotal: 900m);
|
||||||
|
var queue = NewQueueService(context);
|
||||||
|
|
||||||
|
var pending = await queue.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None);
|
||||||
|
Assert.Equal(390m, pending.PendingExposureTotal);
|
||||||
|
Assert.Equal(pending.Items.Sum(i => i.Delta), pending.PendingExposureTotal);
|
||||||
|
|
||||||
|
await queue.ApproveAsync(Admin(), workOrderRequestId, null, CancellationToken.None);
|
||||||
|
await queue.ApproveAsync(Admin(), vendorRequestId, null, CancellationToken.None);
|
||||||
|
|
||||||
|
var decided = await queue.ListAsync(Admin(), "Approved", null, 1, 25, CancellationToken.None);
|
||||||
|
var row = Assert.Single(decided.Items, i => i.Id == workOrderRequestId);
|
||||||
|
Assert.Equal(390m, row.WorkOrderAdminApprovedTotal);
|
||||||
|
// The earlier auto-approved work-order request counts its stored increase.
|
||||||
|
Assert.Equal(500m, row.WorkOrderAutoApprovedTotal);
|
||||||
|
Assert.Equal(0m, (await queue.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None)).PendingExposureTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AutoApprovedWorkOrderRequest_ConsumesAllowanceByItsRequestedIncrease()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
await SeedWorkOrderAsync(context);
|
||||||
|
|
||||||
|
var created = await NewWorkOrderUpliftService(context).CreateAsync(
|
||||||
|
WorkOrderId,
|
||||||
|
new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Within limit" },
|
||||||
|
Admin(),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("auto_approved", created!.Status);
|
||||||
|
var stored = context.DispatchUpliftRequests.Single(u => u.Id == created.Id);
|
||||||
|
Assert.Equal(DispatcherId, stored.createdby);
|
||||||
|
Assert.Equal(400m, stored.RequestedNTE);
|
||||||
|
Assert.Equal(1000m, DispatchOf(context, PrimaryDispatchId).NTEAmount);
|
||||||
|
Assert.Equal(400m, await new UpliftDataService(context)
|
||||||
|
.SumAutoApprovedAmountForWorkOrderAsync(WorkOrderId, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WorkOrderUpliftList_ShowsEachRequestsIncrease()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
await SeedWorkOrderAsync(context);
|
||||||
|
var workOrderRequestId = await CreatePendingWorkOrderRequestAsync(context, 90m);
|
||||||
|
var vendorRequestId = await SeedVendorRequestAsync(context, SecondDispatchId, currentNte: 600m, requestedTotal: 900m);
|
||||||
|
|
||||||
|
var uplifts = await NewWorkOrderUpliftService(context)
|
||||||
|
.ListAsync(WorkOrderId, Admin(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(90m, Assert.Single(uplifts!, u => u.Id == workOrderRequestId).Amount);
|
||||||
|
Assert.Equal(300m, Assert.Single(uplifts!, u => u.Id == vendorRequestId).Amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(DispatcherId, 90, "$90.00")]
|
||||||
|
[InlineData(null, 900, "$300.00")]
|
||||||
|
public void NotificationDeltaRow_ShowsTheRequestedIncrease(string? createdBy, int requestedNte, string expectedDelta)
|
||||||
|
{
|
||||||
|
var dispatch = new Dispatch { Id = PrimaryDispatchId, DispatchNumber = "DIS-10", NTEAmount = 600m };
|
||||||
|
var request = new DispatchUpliftRequest
|
||||||
|
{
|
||||||
|
DispatchId = PrimaryDispatchId,
|
||||||
|
CurrentNTE = 600m,
|
||||||
|
RequestedNTE = requestedNte,
|
||||||
|
RequiredTier = 1,
|
||||||
|
createdby = createdBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Builds the message content only; nothing is sent.
|
||||||
|
var (_, body) = UpliftNotificationMessage.BuildInitial(dispatch, request, "Acme HVAC", "https://example.test");
|
||||||
|
|
||||||
|
Assert.Contains(
|
||||||
|
$"<strong>Delta</strong></td><td style='padding:4px 10px;'>{expectedDelta}</td>",
|
||||||
|
body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AmountDefinition_TranslatesToSqlServer_ForRowAndGroupedSums()
|
||||||
|
{
|
||||||
|
// ToQueryString only builds SQL; it never opens the connection.
|
||||||
|
using var context = new ApplicationDbContext(new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseSqlServer("Server=translation-only;Database=none;Trusted_Connection=True;")
|
||||||
|
.Options);
|
||||||
|
|
||||||
|
var rowSql = context.DispatchUpliftRequests
|
||||||
|
.Select(UpliftAmount.ForQuery)
|
||||||
|
.ToQueryString();
|
||||||
|
var groupedSql = context.DispatchUpliftRequests
|
||||||
|
.GroupBy(u => new { u.DispatchId, u.Status }, UpliftAmount.ForQuery)
|
||||||
|
.Select(g => new { g.Key.DispatchId, g.Key.Status, Total = g.Sum() })
|
||||||
|
.ToQueryString();
|
||||||
|
|
||||||
|
Assert.Contains("CASE", rowSql);
|
||||||
|
Assert.Contains("[createdby] IS NULL", rowSql);
|
||||||
|
Assert.Contains("SUM(CASE", groupedSql);
|
||||||
|
Assert.Contains("GROUP BY", groupedSql);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NoDocumentStorage : 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) =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
public void Delete(int vendorId, int dispatchId, string storedFileName)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
112
SeaHavenIndustries.Tests/VideoDurationProbeTests.cs
Normal file
112
SeaHavenIndustries.Tests/VideoDurationProbeTests.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Text;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class VideoDurationProbeTests
|
||||||
|
{
|
||||||
|
internal static byte[] Box(string type, params byte[][] children)
|
||||||
|
{
|
||||||
|
var body = children.SelectMany(child => child).ToArray();
|
||||||
|
var box = new byte[8 + body.Length];
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length);
|
||||||
|
Encoding.ASCII.GetBytes(type).CopyTo(box, 4);
|
||||||
|
body.CopyTo(box, 8);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static byte[] MovieHeader(uint timescale, ulong duration, bool version1 = false)
|
||||||
|
{
|
||||||
|
var body = new byte[version1 ? 32 : 20];
|
||||||
|
body[0] = version1 ? (byte)1 : (byte)0;
|
||||||
|
if (version1)
|
||||||
|
{
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(20), timescale);
|
||||||
|
BinaryPrimitives.WriteUInt64BigEndian(body.AsSpan(24), duration);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(12), timescale);
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(16), (uint)duration);
|
||||||
|
}
|
||||||
|
return Box("mvhd", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A phone-style file: ftyp, a large mdat, then moov at the end (not fast-start).</summary>
|
||||||
|
internal static byte[] Mp4(uint timescale, ulong duration, bool version1 = false, int mediaBytes = 4096)
|
||||||
|
{
|
||||||
|
var ftyp = Box("ftyp", Encoding.ASCII.GetBytes("isom"), new byte[4]);
|
||||||
|
var mdat = Box("mdat", new byte[mediaBytes]);
|
||||||
|
var moov = Box("moov", MovieHeader(timescale, duration, version1));
|
||||||
|
return ftyp.Concat(mdat).Concat(moov).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReadsDurationFromMoovAfterMediaData()
|
||||||
|
{
|
||||||
|
Assert.Equal(95.0, VideoDurationProbe.TryReadDurationSeconds(new MemoryStream(Mp4(600, 57_000))));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReadsVersionOneMovieHeader()
|
||||||
|
{
|
||||||
|
Assert.Equal(30.5, VideoDurationProbe.TryReadDurationSeconds(
|
||||||
|
new MemoryStream(Mp4(1000, 30_500, version1: true))));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FollowsSixtyFourBitBoxSizes()
|
||||||
|
{
|
||||||
|
var ftyp = Box("ftyp", Encoding.ASCII.GetBytes("qt "), new byte[4]);
|
||||||
|
var mdatBody = new byte[512];
|
||||||
|
var mdat = new byte[16 + mdatBody.Length];
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(mdat, 1);
|
||||||
|
Encoding.ASCII.GetBytes("mdat").CopyTo(mdat, 4);
|
||||||
|
BinaryPrimitives.WriteUInt64BigEndian(mdat.AsSpan(8), (ulong)mdat.Length);
|
||||||
|
var moov = Box("moov", MovieHeader(90_000, 90_000UL * 120));
|
||||||
|
var file = ftyp.Concat(mdat).Concat(moov).ToArray();
|
||||||
|
|
||||||
|
Assert.Equal(120.0, VideoDurationProbe.TryReadDurationSeconds(new MemoryStream(file)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("no-moov")]
|
||||||
|
[InlineData("truncated")]
|
||||||
|
[InlineData("zero-timescale")]
|
||||||
|
[InlineData("oversized-box")]
|
||||||
|
public void UnreadableStructureReturnsNull(string shape)
|
||||||
|
{
|
||||||
|
var bytes = shape switch
|
||||||
|
{
|
||||||
|
"no-moov" => Box("ftyp", Encoding.ASCII.GetBytes("isom"), new byte[4]).Concat(Box("mdat", new byte[64])).ToArray(),
|
||||||
|
"truncated" => Mp4(600, 57_000)[..^10],
|
||||||
|
"zero-timescale" => Mp4(0, 57_000),
|
||||||
|
_ => Box("ftyp", new byte[8]).Concat(new byte[] { 0x7F, 0xFF, 0xFF, 0xFF, (byte)'m', (byte)'o', (byte)'o', (byte)'v' }).ToArray(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Null(VideoDurationProbe.TryReadDurationSeconds(new MemoryStream(bytes)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NonSeekableStreamReturnsNull()
|
||||||
|
{
|
||||||
|
Assert.Null(VideoDurationProbe.TryReadDurationSeconds(new NonSeekableStream(Mp4(600, 57_000))));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(90.0, true)]
|
||||||
|
[InlineData(90.5, false)]
|
||||||
|
public void ContractAllowsUpToNinetySeconds(double seconds, bool allowed)
|
||||||
|
{
|
||||||
|
var message = WorkOrderMediaContract.ValidateVideoDuration(
|
||||||
|
new MemoryStream(Mp4(1000, (ulong)(seconds * 1000))));
|
||||||
|
|
||||||
|
Assert.Equal(allowed ? null : "Videos must be 90 seconds or shorter.", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NonSeekableStream(byte[] bytes) : MemoryStream(bytes)
|
||||||
|
{
|
||||||
|
public override bool CanSeek => false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Globalization;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
@ -55,7 +56,7 @@ public class WorkOrderAdvancedSearchDateRangeTests
|
||||||
return wo;
|
return wo;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static WorkOrderAdvancedSearchQueryDto UnassignedInRange(bool includeDateless = false) => new()
|
private static WorkOrderAdvancedSearchQueryDto UnassignedInRange(bool? includeDateless = null) => new()
|
||||||
{
|
{
|
||||||
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
|
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
|
||||||
DateFrom = RangeFrom,
|
DateFrom = RangeFrom,
|
||||||
|
|
@ -149,6 +150,96 @@ public class WorkOrderAdvancedSearchDateRangeTests
|
||||||
Assert.Equal(new[] { 1, 3 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray());
|
Assert.Equal(new[] { 1, 3 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Payloads the frontend sent before includeDateless existed. They must keep
|
||||||
|
// their undated rows so this backend can deploy ahead of the client.
|
||||||
|
[Theory]
|
||||||
|
[InlineData(2000)] // no range selected: ADVANCED_SEARCH_ALL_WEEKS_FROM
|
||||||
|
[InlineData(1970)] // pinned Unassigned queue: UNASSIGNED_QUEUE_DATE_FROM
|
||||||
|
public async Task SearchAsync_AllWeeksWindowWithoutFlag_KeepsOpenUndatedRows(int fromYear)
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
Wo(1, new DateTime(2026, 8, 4)),
|
||||||
|
Wo(2, null, status: LifecycleStatus.Incomplete),
|
||||||
|
Wo(3, null, status: LifecycleStatus.Completed));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto
|
||||||
|
{
|
||||||
|
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
|
||||||
|
DateFrom = new DateOnly(fromYear, 1, 1),
|
||||||
|
DateTo = new DateOnly(2099, 12, 31),
|
||||||
|
Dispatchers = new List<string> { "__unassigned__" },
|
||||||
|
PageSize = 50
|
||||||
|
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { 1, 2 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchAsync_NoDateInputWithoutFlag_KeepsOpenUndatedRows()
|
||||||
|
{
|
||||||
|
// The WO# duplicate lookup sends only the search text and paging.
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var thisWeek = WorkOrderSearchDateRangeResolver.GetIsoWeekStart(DateOnly.FromDateTime(DateTime.UtcNow));
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
Wo(1, thisWeek.ToDateTime(TimeOnly.MinValue)),
|
||||||
|
Wo(2, null, status: LifecycleStatus.Incomplete),
|
||||||
|
Wo(3, thisWeek.AddDays(-7).ToDateTime(TimeOnly.MinValue)));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await CreateService(context).SearchAsync(
|
||||||
|
new WorkOrderAdvancedSearchQueryDto { PageSize = 100 },
|
||||||
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
||||||
|
null);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { 1, 2 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchAsync_AllWeeksWindowWithFlagFalse_ExcludesUndatedRows()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
Wo(1, new DateTime(2026, 8, 4)),
|
||||||
|
Wo(2, null, status: LifecycleStatus.Incomplete));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto
|
||||||
|
{
|
||||||
|
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
|
||||||
|
DateFrom = new DateOnly(2000, 1, 1),
|
||||||
|
DateTo = new DateOnly(2099, 12, 31),
|
||||||
|
IncludeDateless = false,
|
||||||
|
PageSize = 50
|
||||||
|
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { 1 }, result.Items.Select(i => i.Id).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null, null, null, null, true)]
|
||||||
|
[InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "2000-01-01", "2099-12-31", true)]
|
||||||
|
[InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "1970-01-01", "2099-12-31", true)]
|
||||||
|
[InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "2000-01-02", "2099-12-31", false)]
|
||||||
|
[InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "2026-09-21", "2026-09-25", false)]
|
||||||
|
[InlineData(null, WorkOrderAdvancedSearchDatePreset.ThisWeek, null, null, false)]
|
||||||
|
[InlineData(false, null, null, null, false)]
|
||||||
|
[InlineData(true, WorkOrderAdvancedSearchDatePreset.ThisWeek, null, null, true)]
|
||||||
|
public void ResolveIncludeDateless_InfersOnlyForNoRangeRequests(
|
||||||
|
bool? flag,
|
||||||
|
WorkOrderAdvancedSearchDatePreset? preset,
|
||||||
|
string? from,
|
||||||
|
string? to,
|
||||||
|
bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, WorkOrderSearchDateRangeResolver.ResolveIncludeDateless(
|
||||||
|
flag,
|
||||||
|
preset,
|
||||||
|
from is null ? null : DateOnly.Parse(from, CultureInfo.InvariantCulture),
|
||||||
|
to is null ? null : DateOnly.Parse(to, CultureInfo.InvariantCulture)));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SearchAsync_RangeForAccountUser_NeverReturnsAnotherAccountsRows()
|
public async Task SearchAsync_RangeForAccountUser_NeverReturnsAnotherAccountsRows()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using SeaHaven.DataServices.Implementation;
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
|
@ -203,6 +204,8 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
new WorkOrderDetailDataService(context),
|
new WorkOrderDetailDataService(context),
|
||||||
WorkOrderAccountTestHelpers.Resolver(context),
|
WorkOrderAccountTestHelpers.Resolver(context),
|
||||||
new UserDataService(context),
|
new UserDataService(context),
|
||||||
|
new TeamPermissionOverrideDataService(context),
|
||||||
|
new TeamPermissionPolicy(),
|
||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
Options.Create(new ApprovalsOptions()));
|
Options.Create(new ApprovalsOptions()));
|
||||||
var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts, new PassThroughUpliftData());
|
var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts, new PassThroughUpliftData());
|
||||||
|
|
@ -264,6 +267,8 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
new WorkOrderDetailDataService(context),
|
new WorkOrderDetailDataService(context),
|
||||||
WorkOrderAccountTestHelpers.Resolver(context),
|
WorkOrderAccountTestHelpers.Resolver(context),
|
||||||
new UserDataService(context),
|
new UserDataService(context),
|
||||||
|
new TeamPermissionOverrideDataService(context),
|
||||||
|
new TeamPermissionPolicy(),
|
||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
Options.Create(new ApprovalsOptions()));
|
Options.Create(new ApprovalsOptions()));
|
||||||
var cancel = new WorkOrderBoardCancelService(
|
var cancel = new WorkOrderBoardCancelService(
|
||||||
|
|
@ -300,6 +305,13 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
FirstName = "Alex",
|
FirstName = "Alex",
|
||||||
LastName = "Dispatcher",
|
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.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
|
||||||
seed.Dispatches.Add(new Dispatch
|
seed.Dispatches.Add(new Dispatch
|
||||||
{
|
{
|
||||||
|
|
@ -332,6 +344,8 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
new WorkOrderDetailDataService(createContext),
|
new WorkOrderDetailDataService(createContext),
|
||||||
WorkOrderAccountTestHelpers.Resolver(createContext),
|
WorkOrderAccountTestHelpers.Resolver(createContext),
|
||||||
new UserDataService(createContext),
|
new UserDataService(createContext),
|
||||||
|
new TeamPermissionOverrideDataService(createContext),
|
||||||
|
new TeamPermissionPolicy(),
|
||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
Options.Create(new ApprovalsOptions()));
|
Options.Create(new ApprovalsOptions()));
|
||||||
var boardData = new WorkOrderBoardDataService(cancelContext);
|
var boardData = new WorkOrderBoardDataService(cancelContext);
|
||||||
|
|
@ -345,6 +359,8 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
new WorkOrderDetailDataService(cancelContext),
|
new WorkOrderDetailDataService(cancelContext),
|
||||||
WorkOrderAccountTestHelpers.Resolver(cancelContext),
|
WorkOrderAccountTestHelpers.Resolver(cancelContext),
|
||||||
new UserDataService(cancelContext),
|
new UserDataService(cancelContext),
|
||||||
|
new TeamPermissionOverrideDataService(cancelContext),
|
||||||
|
new TeamPermissionPolicy(),
|
||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
Options.Create(new ApprovalsOptions()));
|
Options.Create(new ApprovalsOptions()));
|
||||||
var cancel = new WorkOrderBoardCancelService(
|
var cancel = new WorkOrderBoardCancelService(
|
||||||
|
|
@ -431,7 +447,6 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
public Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
|
public Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
|
||||||
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) =>
|
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
public Task<decimal> GetPendingExposureAsync(CancellationToken cancellationToken) => throw new NotSupportedException();
|
|
||||||
public Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
|
|
@ -442,6 +457,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)
|
||||||
|
|
|
||||||
|
|
@ -451,6 +451,57 @@ public class WorkOrderBoardPendingUpliftTests
|
||||||
Assert.Equal(1500m, dto.UpliftSummary.Amount);
|
Assert.Equal(1500m, dto.UpliftSummary.Amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetBoardRows_VendorPortalUplift_SummaryAmountIsTheRequestedIncrease()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var weekStart = new DateOnly(2026, 6, 22);
|
||||||
|
|
||||||
|
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
|
||||||
|
context.Dispatches.Add(new Dispatch
|
||||||
|
{
|
||||||
|
Id = 10,
|
||||||
|
VendorId = 1,
|
||||||
|
WorkOrderId = 1,
|
||||||
|
NTEAmount = 600m,
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0)
|
||||||
|
});
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
InternalWONumber = "10000000001",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 23),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
PrimaryDispatchId = 10,
|
||||||
|
SiteCode = "BK5"
|
||||||
|
});
|
||||||
|
// A vendor-portal request (no identity user) stores the requested NTE total.
|
||||||
|
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||||
|
{
|
||||||
|
Id = 100,
|
||||||
|
DispatchId = 10,
|
||||||
|
CurrentNTE = 600m,
|
||||||
|
RequestedNTE = 900m,
|
||||||
|
Status = "Pending",
|
||||||
|
RequiredTier = 1,
|
||||||
|
NotificationStatus = "Pending"
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await new WorkOrderBoardDataService(context).GetBoardRowsAsync(new WorkOrderBoardQuery(
|
||||||
|
weekStart,
|
||||||
|
weekStart.AddDays(4),
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null));
|
||||||
|
|
||||||
|
var dto = WorkOrderBoardService.MapRawRow(Assert.Single(result.ScheduledRows), DateTime.UtcNow);
|
||||||
|
Assert.Equal(300m, dto.UpliftSummary!.Amount);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetBoardRows_ApprovedUplift_DoesNotBlock()
|
public async Task GetBoardRows_ApprovedUplift_DoesNotBlock()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,8 @@ public class WorkOrderCompletedSelectiveLockTests
|
||||||
new WorkOrderMediaDataService(context),
|
new WorkOrderMediaDataService(context),
|
||||||
new WorkOrderDetailDataService(context),
|
new WorkOrderDetailDataService(context),
|
||||||
audit,
|
audit,
|
||||||
new TestFileStoragePort());
|
new TestFileStoragePort(),
|
||||||
|
new UpliftDataService(context));
|
||||||
return (context, service);
|
return (context, service);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,8 @@ public class WorkOrderMediaConcurrencyRelationalTests
|
||||||
new WorkOrderMediaDataService(context),
|
new WorkOrderMediaDataService(context),
|
||||||
new WorkOrderDetailDataService(context),
|
new WorkOrderDetailDataService(context),
|
||||||
audit,
|
audit,
|
||||||
new TestFileStoragePort());
|
new TestFileStoragePort(),
|
||||||
|
new UpliftDataService(context));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<string> LoadVersionAsync(ApplicationDbContext context, int workOrderId)
|
private static async Task<string> LoadVersionAsync(ApplicationDbContext context, int workOrderId)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
@ -812,7 +814,8 @@ public class WorkOrderMediaServiceTests
|
||||||
new WorkOrderMediaDataService(context),
|
new WorkOrderMediaDataService(context),
|
||||||
new WorkOrderDetailDataService(context),
|
new WorkOrderDetailDataService(context),
|
||||||
audit,
|
audit,
|
||||||
fileStorage ?? new TestFileStoragePort());
|
fileStorage ?? new TestFileStoragePort(),
|
||||||
|
new UpliftDataService(context));
|
||||||
return (context, service);
|
return (context, service);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1309,6 +1312,410 @@ public class WorkOrderMediaServiceTests
|
||||||
Assert.Equal(WorkOrderMediaCategory.Extra, media.Category);
|
Assert.Equal(WorkOrderMediaCategory.Extra, media.Category);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_EleventhPhoto_ThrowsMediaCountExceeded()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
});
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = 1,
|
||||||
|
Attachments = $"https://example.com/photo-{i}.jpg",
|
||||||
|
Category = WorkOrderMediaCategory.Extra
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
service.AddMediaAsync(
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
"https://example.com/photo-10.jpg",
|
||||||
|
AuthenticatedUser(),
|
||||||
|
"actor-1"));
|
||||||
|
|
||||||
|
Assert.Equal("MediaCountExceeded", ex.Code);
|
||||||
|
Assert.Equal("A work order can have at most 10 photos.", ex.Message);
|
||||||
|
Assert.Equal(10, context.workOrderAttachments.Count(a => a.IsDeleted != true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_CountsAndInsertsUnderTheWorkOrderMutationLock()
|
||||||
|
{
|
||||||
|
// Distinct id: the mutation gate is process-wide per work order.
|
||||||
|
const int workOrderId = 173_001;
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = workOrderId,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
});
|
||||||
|
for (var i = 0; i < 9; i++)
|
||||||
|
{
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = workOrderId,
|
||||||
|
Attachments = $"https://example.com/photo-{i}.jpg",
|
||||||
|
Category = WorkOrderMediaCategory.Extra
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var holder = new UpliftDataService(context).ExecuteWorkOrderMutationAsync(
|
||||||
|
workOrderId,
|
||||||
|
async _ =>
|
||||||
|
{
|
||||||
|
held.SetResult();
|
||||||
|
await release.Task;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
CancellationToken.None);
|
||||||
|
await held.Task;
|
||||||
|
|
||||||
|
var upload = service.AddMediaAsync(
|
||||||
|
workOrderId,
|
||||||
|
null,
|
||||||
|
"https://example.com/photo-9.jpg",
|
||||||
|
AuthenticatedUser(),
|
||||||
|
"actor-1");
|
||||||
|
await Task.Delay(200);
|
||||||
|
|
||||||
|
Assert.False(upload.IsCompleted);
|
||||||
|
Assert.Equal(9, context.workOrderAttachments.Count(a => a.WorkorderId == workOrderId && a.IsDeleted != true));
|
||||||
|
|
||||||
|
release.SetResult();
|
||||||
|
await holder;
|
||||||
|
await upload;
|
||||||
|
|
||||||
|
Assert.Equal(10, context.workOrderAttachments.Count(a => a.WorkorderId == workOrderId && a.IsDeleted != true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_FourthVideo_CountsStoredPhoneFileUrls()
|
||||||
|
{
|
||||||
|
// Same URL shape FileStorageAdapter.SaveFileAsync returns for an iPhone upload.
|
||||||
|
static string StoredUrl(string name) =>
|
||||||
|
$"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}";
|
||||||
|
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
});
|
||||||
|
foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.mov", "clip.MP4" })
|
||||||
|
{
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = 1,
|
||||||
|
Attachments = StoredUrl(name),
|
||||||
|
Category = WorkOrderMediaCategory.Extra
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
service.AddMediaAsync(
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
StoredUrl("IMG_0004.MOV"),
|
||||||
|
AuthenticatedUser(),
|
||||||
|
"actor-1"));
|
||||||
|
|
||||||
|
Assert.Equal("MediaCountExceeded", ex.Code);
|
||||||
|
Assert.Equal("A work order can have at most 3 videos.", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_FourthVideo_CountsVendorPortalVideos()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
});
|
||||||
|
foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV" })
|
||||||
|
{
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = 1,
|
||||||
|
Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}",
|
||||||
|
Category = WorkOrderMediaCategory.Extra
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Vendor v1 video was replaced by v2 (also a video): only v2 is current.
|
||||||
|
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
|
||||||
|
{
|
||||||
|
Id = 50,
|
||||||
|
WorkOrderId = 1,
|
||||||
|
DispatchId = 7,
|
||||||
|
VendorId = 3,
|
||||||
|
Version = 1,
|
||||||
|
ContentType = "video/mp4",
|
||||||
|
Purpose = "Completion"
|
||||||
|
});
|
||||||
|
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
|
||||||
|
{
|
||||||
|
Id = 51,
|
||||||
|
WorkOrderId = 1,
|
||||||
|
DispatchId = 7,
|
||||||
|
VendorId = 3,
|
||||||
|
Version = 2,
|
||||||
|
ContentType = "video/quicktime",
|
||||||
|
Purpose = "Completion",
|
||||||
|
ReplacesDocumentId = 50
|
||||||
|
});
|
||||||
|
// Another work order's vendor video never counts here.
|
||||||
|
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
|
||||||
|
{
|
||||||
|
Id = 60,
|
||||||
|
WorkOrderId = 2,
|
||||||
|
DispatchId = 8,
|
||||||
|
VendorId = 3,
|
||||||
|
Version = 1,
|
||||||
|
ContentType = "video/mp4",
|
||||||
|
Purpose = "Completion"
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
service.AddMediaAsync(
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
"https://api.example.com/Assets/Documents/x_IMG_0004.MOV",
|
||||||
|
AuthenticatedUser(),
|
||||||
|
"actor-1"));
|
||||||
|
|
||||||
|
Assert.Equal("MediaCountExceeded", ex.Code);
|
||||||
|
Assert.Equal("A work order can have at most 3 videos.", ex.Message);
|
||||||
|
Assert.Equal(2, context.workOrderAttachments.Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_CrossAccount_FullWorkOrder_ThrowsNotFoundNotCount()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
AccountId = 20,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
});
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = 1,
|
||||||
|
Attachments = $"https://example.com/photo-{i}.jpg",
|
||||||
|
Category = WorkOrderMediaCategory.Extra
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
service.AddMediaAsync(
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
"https://example.com/photo-10.jpg",
|
||||||
|
AuthenticatedUser(accountId: 10),
|
||||||
|
"actor-1"));
|
||||||
|
|
||||||
|
// Another account must not learn the work order exists or how full it is.
|
||||||
|
Assert.Equal("NotFound", ex.Code);
|
||||||
|
Assert.Equal(10, context.workOrderAttachments.Count(a => a.IsDeleted != true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_TenthPhoto_Succeeds()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
});
|
||||||
|
for (var i = 0; i < 9; i++)
|
||||||
|
{
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = 1,
|
||||||
|
Attachments = $"https://example.com/photo-{i}.jpg",
|
||||||
|
Category = WorkOrderMediaCategory.Extra
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var media = await service.AddMediaAsync(
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
"https://example.com/photo-9.jpg",
|
||||||
|
AuthenticatedUser(),
|
||||||
|
"actor-1");
|
||||||
|
|
||||||
|
Assert.True(media.Id > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_FourthVideo_ThrowsMediaCountExceeded()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
});
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = 1,
|
||||||
|
Attachments = "https://example.com/clip-a.mp4",
|
||||||
|
Category = WorkOrderMediaCategory.Extra
|
||||||
|
});
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = 1,
|
||||||
|
Attachments = "https://example.com/clip-b.mov",
|
||||||
|
Category = WorkOrderMediaCategory.Extra
|
||||||
|
});
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = 1,
|
||||||
|
Attachments = "https://example.com/clip-c.mp4",
|
||||||
|
Category = WorkOrderMediaCategory.Extra
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
service.AddMediaAsync(
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
"https://example.com/clip-d.mov",
|
||||||
|
AuthenticatedUser(),
|
||||||
|
"actor-1"));
|
||||||
|
|
||||||
|
Assert.Equal("MediaCountExceeded", ex.Code);
|
||||||
|
Assert.Equal("A work order can have at most 3 videos.", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_ThirdVideo_Succeeds()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
});
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = 1,
|
||||||
|
Attachments = "https://example.com/clip-a.mp4",
|
||||||
|
Category = WorkOrderMediaCategory.Extra
|
||||||
|
});
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = 1,
|
||||||
|
Attachments = "https://example.com/clip-b.mov",
|
||||||
|
Category = WorkOrderMediaCategory.Extra
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var media = await service.AddMediaAsync(
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
"https://example.com/clip-c.mp4",
|
||||||
|
AuthenticatedUser(),
|
||||||
|
"actor-1");
|
||||||
|
|
||||||
|
Assert.True(media.Id > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_DeletedPhoto_DoesNotConsumePhotoCount()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
});
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = 1,
|
||||||
|
Attachments = $"https://example.com/photo-{i}.jpg",
|
||||||
|
Category = WorkOrderMediaCategory.Extra,
|
||||||
|
IsDeleted = i == 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var media = await service.AddMediaAsync(
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
"https://example.com/photo-new.jpg",
|
||||||
|
AuthenticatedUser(),
|
||||||
|
"actor-1");
|
||||||
|
|
||||||
|
Assert.True(media.Id > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_DocumentsDoNotConsumePhotoOrVideoCounts()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
});
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = 1,
|
||||||
|
Attachments = $"https://example.com/report-{i}.pdf",
|
||||||
|
Category = WorkOrderMediaCategory.Extra
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var photo = await service.AddMediaAsync(
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
"https://example.com/photo.jpg",
|
||||||
|
AuthenticatedUser(),
|
||||||
|
"actor-1");
|
||||||
|
var video = await service.AddMediaAsync(
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
"https://example.com/clip.mp4",
|
||||||
|
AuthenticatedUser(),
|
||||||
|
"actor-1");
|
||||||
|
|
||||||
|
Assert.True(photo.Id > 0);
|
||||||
|
Assert.True(video.Id > 0);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteMedia_Technician_ThrowsForbidden()
|
public async Task DeleteMedia_Technician_ThrowsForbidden()
|
||||||
{
|
{
|
||||||
|
|
@ -1968,6 +2375,32 @@ public class WorkOrderMediaFileRulesTests
|
||||||
return stream.ToArray();
|
return stream.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnsureAllowed_RejectionMessage_NamesEveryAllowedType()
|
||||||
|
{
|
||||||
|
const BindingFlags privateStatic = BindingFlags.NonPublic | BindingFlags.Static;
|
||||||
|
var allowedTypes = (HashSet<string>?)typeof(WorkOrderMediaFileRules)
|
||||||
|
.GetField("AllowedContentTypes", privateStatic)?.GetValue(null);
|
||||||
|
var extensionsByType = (Dictionary<string, HashSet<string>>?)typeof(WorkOrderMediaFileRules)
|
||||||
|
.GetField("ExtensionsByContentType", privateStatic)?.GetValue(null);
|
||||||
|
Assert.NotNull(allowedTypes);
|
||||||
|
Assert.NotNull(extensionsByType);
|
||||||
|
Assert.NotEmpty(allowedTypes);
|
||||||
|
|
||||||
|
var ex = Assert.Throws<WorkOrderBoardValidationException>(
|
||||||
|
() => WorkOrderMediaFileRules.EnsureAllowed(FormFile(Encoding.UTF8.GetBytes("plain text"), "notes.txt", "text/plain")));
|
||||||
|
|
||||||
|
foreach (var contentType in allowedTypes)
|
||||||
|
{
|
||||||
|
var canonical = contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" : contentType;
|
||||||
|
Assert.True(extensionsByType.TryGetValue(canonical, out var extensions), $"No extensions mapped for {contentType}.");
|
||||||
|
var labels = extensions.Select(extension => extension.TrimStart('.').ToUpperInvariant()).ToList();
|
||||||
|
Assert.True(
|
||||||
|
labels.Any(label => Regex.IsMatch(ex.Message, $@"\b{Regex.Escape(label)}\b")),
|
||||||
|
$"Rejection message does not name {contentType} ({string.Join("/", labels)}): {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void IsAllowed_ValidJpeg_ReturnsTrue()
|
public void IsAllowed_ValidJpeg_ReturnsTrue()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,352 @@
|
||||||
|
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.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" });
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
new TeamPermissionOverrideDataService(context),
|
||||||
|
new TeamPermissionPolicy(),
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using SeaHaven.DataServices.Implementation;
|
using SeaHaven.DataServices.Implementation;
|
||||||
using SeaHaven.Services.Configuration;
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.Constants;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Implementation;
|
using SeaHaven.Services.Implementation;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
@ -37,6 +39,8 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
new WorkOrderDetailDataService(context),
|
new WorkOrderDetailDataService(context),
|
||||||
WorkOrderAccountTestHelpers.Resolver(context),
|
WorkOrderAccountTestHelpers.Resolver(context),
|
||||||
new UserDataService(context),
|
new UserDataService(context),
|
||||||
|
new TeamPermissionOverrideDataService(context),
|
||||||
|
new TeamPermissionPolicy(),
|
||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
Options.Create(NewOptions()));
|
Options.Create(NewOptions()));
|
||||||
}
|
}
|
||||||
|
|
@ -47,6 +51,7 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync(
|
private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync(
|
||||||
ApplicationDbContext context,
|
ApplicationDbContext context,
|
||||||
WorkOrderType type = WorkOrderType.PM,
|
WorkOrderType type = WorkOrderType.PM,
|
||||||
|
string role = "Admin",
|
||||||
int? primaryDispatchId = 10)
|
int? primaryDispatchId = 10)
|
||||||
{
|
{
|
||||||
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
|
||||||
|
|
@ -57,6 +62,13 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
FirstName = "Alex",
|
FirstName = "Alex",
|
||||||
LastName = "Dispatcher",
|
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.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
|
||||||
context.Dispatches.Add(new Dispatch
|
context.Dispatches.Add(new Dispatch
|
||||||
{
|
{
|
||||||
|
|
@ -124,6 +136,22 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
|
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]
|
[Fact]
|
||||||
public async Task CreateAsync_WithoutPrimaryDispatch_UsesWorkOrderDispatch()
|
public async Task CreateAsync_WithoutPrimaryDispatch_UsesWorkOrderDispatch()
|
||||||
{
|
{
|
||||||
|
|
@ -174,6 +202,109 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
Assert.Equal(workOrder.Id, audit.WorkOrderId);
|
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]
|
[Fact]
|
||||||
public async Task CreateAsync_PmAmountAboveCap_CreatesPendingRequest()
|
public async Task CreateAsync_PmAmountAboveCap_CreatesPendingRequest()
|
||||||
{
|
{
|
||||||
|
|
@ -289,7 +420,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,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ def is_app_path(path: str) -> bool:
|
||||||
normalized = path.replace("\\", "/")
|
normalized = path.replace("\\", "/")
|
||||||
if normalized in APP_SCRIPT_NAMES:
|
if normalized in APP_SCRIPT_NAMES:
|
||||||
return True
|
return True
|
||||||
if normalized.startswith(".ebextensions/"):
|
if normalized.startswith((".ebextensions/", ".platform/")):
|
||||||
return True
|
return True
|
||||||
return normalized.endswith(APP_SUFFIXES)
|
return normalized.endswith(APP_SUFFIXES)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
# ./ published Api.SeaHavenIndustries (self-contained, linux-x64)
|
# ./ published Api.SeaHavenIndustries (self-contained, linux-x64)
|
||||||
# ./efbundle self-contained EF Core 8.0.8 migrations bundle (linux-x64, +x)
|
# ./efbundle self-contained EF Core 8.0.8 migrations bundle (linux-x64, +x)
|
||||||
# ./.ebextensions/* leader-only migration container command
|
# ./.ebextensions/* leader-only migration container command
|
||||||
|
# ./.platform/nginx/conf.d/* nginx proxy overrides (upload body size)
|
||||||
#
|
#
|
||||||
# The bundle reads ConnectionStrings__DefaultConnection from the runtime
|
# The bundle reads ConnectionStrings__DefaultConnection from the runtime
|
||||||
# environment (Elastic Beanstalk property). No connection string or secret is
|
# environment (Elastic Beanstalk property). No connection string or secret is
|
||||||
|
|
@ -137,6 +138,10 @@ log "copy .ebextensions into bundle root"
|
||||||
mkdir -p "$STAGING_DIR/.ebextensions"
|
mkdir -p "$STAGING_DIR/.ebextensions"
|
||||||
cp -R .ebextensions/. "$STAGING_DIR/.ebextensions/"
|
cp -R .ebextensions/. "$STAGING_DIR/.ebextensions/"
|
||||||
|
|
||||||
|
log "copy .platform (nginx proxy overrides) into bundle root"
|
||||||
|
mkdir -p "$STAGING_DIR/.platform"
|
||||||
|
cp -R .platform/. "$STAGING_DIR/.platform/"
|
||||||
|
|
||||||
log "verify no committed secret placeholders survived publish"
|
log "verify no committed secret placeholders survived publish"
|
||||||
if grep -rIEl -- 'Server=.*;.*Password=|AccountKey=|aws_secret|AKIA[0-9A-Z]{16}' "$STAGING_DIR" 2>/dev/null; then
|
if grep -rIEl -- 'Server=.*;.*Password=|AccountKey=|aws_secret|AKIA[0-9A-Z]{16}' "$STAGING_DIR" 2>/dev/null; then
|
||||||
die "potential secret detected in publish output; refusing to package."
|
die "potential secret detected in publish output; refusing to package."
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,17 @@ class IsolationTests(unittest.TestCase):
|
||||||
self.assertEqual(terraform_files, ["terraform/live/dev/main.tf"])
|
self.assertEqual(terraform_files, ["terraform/live/dev/main.tf"])
|
||||||
self.assertTrue(any(path.endswith(".cs") for path in app_files))
|
self.assertTrue(any(path.endswith(".cs") for path in app_files))
|
||||||
|
|
||||||
|
def test_platform_proxy_config_is_app_side(self) -> None:
|
||||||
|
violation = isolation_violation(
|
||||||
|
[
|
||||||
|
"terraform/live/dev/main.tf",
|
||||||
|
".platform/nginx/conf.d/01_upload_body_size.conf",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(violation)
|
||||||
|
_, app_files = violation or ([], [])
|
||||||
|
self.assertEqual(app_files, [".platform/nginx/conf.d/01_upload_body_size.conf"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,15 @@ die() {
|
||||||
|
|
||||||
contents_file="$(mktemp)"
|
contents_file="$(mktemp)"
|
||||||
webhook_file="$(mktemp)"
|
webhook_file="$(mktemp)"
|
||||||
trap 'rm -f "$contents_file" "$webhook_file"' EXIT
|
nginx_file="$(mktemp)"
|
||||||
|
trap 'rm -f "$contents_file" "$webhook_file" "$nginx_file"' EXIT
|
||||||
|
|
||||||
unzip -tq "$BUNDLE"
|
unzip -tq "$BUNDLE"
|
||||||
unzip -Z1 "$BUNDLE" > "$contents_file"
|
unzip -Z1 "$BUNDLE" > "$contents_file"
|
||||||
grep -Fxq "efbundle" "$contents_file"
|
grep -Fxq "efbundle" "$contents_file"
|
||||||
grep -Fxq ".ebextensions/01_migrations.config" "$contents_file"
|
grep -Fxq ".ebextensions/01_migrations.config" "$contents_file"
|
||||||
grep -Fxq ".ebextensions/02_webhook_config.config" "$contents_file"
|
grep -Fxq ".ebextensions/02_webhook_config.config" "$contents_file"
|
||||||
|
grep -Fxq ".platform/nginx/conf.d/01_upload_body_size.conf" "$contents_file"
|
||||||
|
|
||||||
unzip -p "$BUNDLE" .ebextensions/02_webhook_config.config > "$webhook_file"
|
unzip -p "$BUNDLE" .ebextensions/02_webhook_config.config > "$webhook_file"
|
||||||
grep -Fxq ' WorkOrderWebhook__Enabled: "true"' "$webhook_file"
|
grep -Fxq ' WorkOrderWebhook__Enabled: "true"' "$webhook_file"
|
||||||
|
|
@ -30,5 +32,9 @@ grep -Fxq \
|
||||||
' WorkOrderWebhook__SecretId: arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB' \
|
' WorkOrderWebhook__SecretId: arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB' \
|
||||||
"$webhook_file"
|
"$webhook_file"
|
||||||
|
|
||||||
|
# Without this override the platform nginx caps request bodies at 1 MB.
|
||||||
|
unzip -p "$BUNDLE" .platform/nginx/conf.d/01_upload_body_size.conf > "$nginx_file"
|
||||||
|
grep -Fxq 'client_max_body_size 120M;' "$nginx_file"
|
||||||
|
|
||||||
printf 'PASS: Elastic Beanstalk bundle contract (%s bytes)\n' \
|
printf 'PASS: Elastic Beanstalk bundle contract (%s bytes)\n' \
|
||||||
"$(wc -c < "$BUNDLE" | tr -d ' ')"
|
"$(wc -c < "$BUNDLE" | tr -d ' ')"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue