Compare commits

...

30 commits

Author SHA1 Message Date
Alexandre Brandizzi
d4d322596d
Merge 0d8f32d148 into 59b8cdaf7d 2026-09-24 23:47:22 -03:00
Alexandre Brandizzi
0d8f32d148 fix(media): check the file type before the size cap on media upload
AddMedia sized a file before validating its type, and ValidateSize's Unknown
arm returned the video message, so a 150 MB .exe sent as
application/octet-stream was rejected as FileTooLarge with "Videos must be
100 MB or smaller." EnsureAllowed now runs first, so an unsupported file always
reports UnsupportedMediaType, and the Unknown arm uses a type-neutral message.
The oversize controller tests now use real file headers so they pass the type
check before reaching the size cap.
2026-09-24 23:47:18 -03:00
Alexandre Brandizzi
59b8cdaf7d
Merge pull request #168 from Sea-Haven-Industries/fix/sh-327-request-uplifts-permission
fix(uplifts): enforce request permission at service boundary
2026-09-25 02:35:19 +00:00
Alexandre Brandizzi
8185875ad2
Merge pull request #176 from Sea-Haven-Industries/fix/ab/sh-327-admin-approves-uplifts
fix(uplifts): Admin passes uplift approval checks regardless of tier config (SH-327)
2026-09-25 02:13:26 +00:00
Alexandre Brandizzi
f3ef11b504 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	Api.SeaHavenIndustries/Controllers/VendorPortalController.cs
#	SeaHaven.Services/Implementation/VendorPortalService.cs
2026-09-24 23:05:17 -03:00
Alexandre Brandizzi
beb091fcc8 Merge remote-tracking branch 'origin/main' into lane/sh-327
# Conflicts:
#	Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs
2026-09-24 23:00:18 -03:00
Alexandre Brandizzi
1e2f39a5fa chore(uplifts): drop ticket key from source comments 2026-09-24 22:57:09 -03:00
Alexandre Brandizzi
3e3f0f383d fix(media): serialize SH-116 media counts under the work-order lock and serve HEIC as image/heic
The 10-photo / 3-video cap was a check-then-insert with no lock on both
upload surfaces, so two overlapping uploads could both take the last slot.
The board media upload and the vendor portal upload now run count, insert
and save inside ExecuteWorkOrderMutationAsync. GetMediaContent maps .heic
to image/heic.
2026-09-24 22:56:21 -03:00
Alexandre Brandizzi
7591869462
Merge pull request #169 from Sea-Haven-Industries/hotfix/sh-387-concurrent-uplift
fix: return conflict for concurrent dispatch uplift requests
2026-09-25 01:54:50 +00:00
Alexandre Brandizzi
26b4aaf1b2 test(uplifts): cover Admin request-changes and evidence download with empty tier config (SH-327) 2026-09-24 22:51:57 -03:00
Alexandre Brandizzi
cfb05a906f fix(uplifts): Admin passes uplift approval checks regardless of tier config (SH-327)
UserCanApprove only accepted roles listed in Approvals:Tier1Roles/Tier2Roles,
so an environment whose config omits Admin denied every approval surface to
admins: can-approve, per-row CanDecide, approve/reject/request-changes and
evidence download. Admin now short-circuits the check; tier-role config still
governs every other role.
2026-09-24 22:34:32 -03:00
Alexandre Brandizzi
b116e71d16 test(uplifts): grant the SH-393 ownership fixture actor RequestUplifts
Uplift creation now checks the actor's RequestUplifts permission, so the
ownership tests construct the service with the permission services and seed
their actor as an Admin.
2026-09-24 22:31:07 -03:00
Alexandre Brandizzi
cc7cda4818 Merge remote-tracking branch 'origin/main' into lane/sh-327 2026-09-24 22:28:15 -03:00
Alexandre Brandizzi
2be74b261f test(uplifts): share conflict fixture dispatch through owned and linked work orders
After SH-393 the uplift dispatch resolves only through dispatches the work
order owns or links through DispatchWorkOrders. Model the shared dispatch
that way so the concurrent-insert conflict test exercises the conflict
mapping again; assertions are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:28:04 -03:00
Alexandre Brandizzi
24ad21d7f2 Merge remote-tracking branch 'origin/main' into lane/sh-387 2026-09-24 22:25:54 -03:00
Alexandre Brandizzi
7b7df4463e
Merge pull request #174 from Sea-Haven-Industries/fix/ab/sh-393-uplift-scope
Some checks are pending
Backend CI / Build and test (push) Waiting to run
Backend CI / architecture (push) Waiting to run
Backend CI / review (push) Waiting to run
Backend CI / ci-complete (push) Blocked by required conditions
fix(uplifts): uplifts created from a work order show on that work order (SH-393)
2026-09-25 00:54:42 +00:00
Alexandre Brandizzi
76b13fb5da
Merge pull request #170 from Sea-Haven-Industries/fix/ab/sh-377-atomic-team-member-create
fix: make team member creation atomic
2026-09-25 00:52:34 +00:00
Alexandre Brandizzi
ce3b241991 test(uplifts): prove board-create dispatch ownership persists relationally (SH-393)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:45:00 -03:00
Alexandre Brandizzi
89376e99e4 fix(uplifts): resolve work-order uplifts through owned dispatches (SH-393)
Board create left the new primary dispatch with no WorkOrderId, so uplifts
created on those work orders were written to a dispatch the work order's
uplift reads never resolve: not listed, allowance never consumed, queue WO
number blank. The same orphan made ApptDate/vendor patches fail with
"A primary dispatch is required".

- Board create backfills Dispatch.WorkOrderId after the first save.
- Uplift create resolves its dispatch through the read-side scope
  (non-deleted, owned or linked); otherwise the stable
  "no primary dispatch" error.
- Data-only migration assigns existing orphaned primaries to the single
  work order naming them primary; idempotent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:22:13 -03:00
Alexandre Brandizzi
fddb7b7909 test: verify team member commit and rollback 2026-09-23 03:49:57 -03:00
Alexandre Brandizzi
5aa3a6f820 test: exercise team member rollback through DI 2026-09-23 03:44:44 -03:00
Alexandre Brandizzi
3b626cca58 fix: make team member creation atomic 2026-09-23 03:35:23 -03:00
Alexandre Brandizzi
0ca9b767ed fix: distinguish uplift request key conflicts 2026-09-23 01:39:16 -03:00
Alexandre Brandizzi
8af9ad076f fix: map concurrent uplift inserts to conflict 2026-09-23 01:26:15 -03:00
Alexandre Brandizzi
0b39d92723 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs
2026-09-23 00:57:01 -03:00
Alexandre Brandizzi
50a2cbab5f test(uplifts): correct current-role denial fixtures 2026-09-22 23:39:51 -03:00
Alexandre Brandizzi
e1632bc3bf fix(uplifts): expose only allowlisted denial copy 2026-09-22 23:35:07 -03:00
Alexandre Brandizzi
f4284badf2 fix(uplifts): place forbidden handler on request endpoint 2026-09-22 23:30:14 -03:00
Alexandre Brandizzi
ff6a5945f1 fix(uplifts): honor current permissions and denial contract 2026-09-22 23:24:17 -03:00
Alexandre Brandizzi
9156107e72 fix(uplifts): enforce request permission at service boundary 2026-09-22 23:09:47 -03:00
37 changed files with 6212 additions and 171 deletions

View file

@ -319,7 +319,7 @@ public sealed class TeamMemberServiceTests
[Fact]
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
.Setup(manager => manager.FindByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((ApplicationUser?)null);
@ -332,10 +332,57 @@ public sealed class TeamMemberServiceTests
result.Success.Should().BeFalse();
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.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(
out Mock<UserManager<ApplicationUser>> userManager,
out Mock<RoleManager<IdentityRole>> roleManager,
@ -350,6 +397,11 @@ public sealed class TeamMemberServiceTests
overrides = new Mock<ITeamPermissionOverrideDataService>();
userData = new Mock<IUserDataService>();
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(
userManager.Object,
roleManager.Object,

View 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) { }
}
}

View file

@ -139,7 +139,8 @@ public sealed class UpliftWorkflowTests
private static VendorPortalService NewPortalService(
ApplicationDbContext context,
IEmailSender emailSender,
IVendorDocumentStoragePort? storage = null)
IVendorDocumentStoragePort? storage = null,
IUpliftDataService? upliftData = null)
{
var vendorData = new VendorDataService(context);
var tokenService = new VendorPortalTokenService(vendorData, Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions()));
@ -150,7 +151,7 @@ public sealed class UpliftWorkflowTests
return new VendorPortalService(
tokenService,
new DispatchDataService(context),
new UpliftDataService(context),
upliftData ?? new UpliftDataService(context),
commentData.Object,
Mock.Of<IUserDataService>(),
emailSender,
@ -214,6 +215,36 @@ public sealed class UpliftWorkflowTests
result.RequiredTier.Should().Be(2);
}
[Fact]
public async Task RequestUplift_ConcurrentActiveRequest_MapsPersistenceConflict()
{
using var context = NewContext();
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
await context.SaveChangesAsync();
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(x => x.HasActiveAsync(dispatch.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
upliftData.Setup(x => x.StageAsync(It.IsAny<DispatchUpliftRequest>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
upliftData.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ThrowsAsync(new SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException());
var service = NewPortalService(context, new FakeEmailSender(deliver: true), upliftData: upliftData.Object);
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var act = () => service.RequestUpliftAsync(
session!,
dispatch.Id,
1500m,
"reason",
null,
1,
CancellationToken.None);
await act.Should().ThrowAsync<SeaHaven.Services.Exceptions.UpliftConflictException>();
}
// --- Evidence scan + cross-vendor/dispatch scoping ---
[Fact]

View file

@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
using Xunit;
@ -309,6 +310,34 @@ public class VendorPortalControllerTests
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task RequestUplift_ConcurrentActiveRequest_ReturnsConflict()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.RequestUpliftAsync(
Session,
10,
1500m,
"Need more parts",
It.IsAny<string?>(),
It.IsAny<int?>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new UpliftConflictException());
var controller = NewController(service);
var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody
{
RequestedNTE = 1500m,
Reason = "Need more parts",
});
var conflict = result.Should().BeOfType<ConflictObjectResult>().Subject;
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
conflict.Value.Should().NotBeNull();
}
[Fact]
public async Task RequestUplift_Success_ReturnsResult()
{

View file

@ -54,6 +54,11 @@ public sealed class VendorPortalDocumentTests : IDisposable
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.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>();
commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PortalCommentData>());

View file

@ -56,14 +56,11 @@ public class WorkOrderMediaControllerTests
[Fact]
public async Task AddMedia_VideoOverHundredMegabytes_ReturnsStableUnprocessableEntity()
{
var file = new Mock<IFormFile>();
file.SetupGet(candidate => candidate.Length).Returns(100_000_001);
file.SetupGet(candidate => candidate.FileName).Returns("clip.mp4");
file.SetupGet(candidate => candidate.ContentType).Returns("video/mp4");
var file = OversizeFile(100_000_001, "clip.mp4", "video/mp4", FtypHeader);
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
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 error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
@ -75,14 +72,11 @@ public class WorkOrderMediaControllerTests
[Fact]
public async Task AddMedia_PhotoOverTenMegabytes_ReturnsStableUnprocessableEntity()
{
var file = new Mock<IFormFile>();
file.SetupGet(candidate => candidate.Length).Returns(10_000_001);
file.SetupGet(candidate => candidate.FileName).Returns("photo.jpg");
file.SetupGet(candidate => candidate.ContentType).Returns("image/jpeg");
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.Object, CancellationToken.None);
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
@ -96,14 +90,11 @@ public class WorkOrderMediaControllerTests
{
// 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 = new Mock<IFormFile>();
file.SetupGet(candidate => candidate.Length).Returns(60_000_000);
file.SetupGet(candidate => candidate.FileName).Returns("photo.jpg");
file.SetupGet(candidate => candidate.ContentType).Returns("video/3gpp");
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.Object, CancellationToken.None);
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
@ -115,14 +106,11 @@ public class WorkOrderMediaControllerTests
[Fact]
public async Task AddMedia_DocumentOverFiftyMegabytes_ReturnsStableUnprocessableEntity()
{
var file = new Mock<IFormFile>();
file.SetupGet(candidate => candidate.Length).Returns(50_000_001);
file.SetupGet(candidate => candidate.FileName).Returns("report.pdf");
file.SetupGet(candidate => candidate.ContentType).Returns("application/pdf");
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.Object, CancellationToken.None);
var result = await controller.AddMedia(1, WorkOrderMediaCategory.Extra, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
@ -131,6 +119,48 @@ public class WorkOrderMediaControllerTests
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];
@ -395,7 +425,8 @@ public class WorkOrderMediaServiceCancellationTests
mediaData.Object,
new Mock<IWorkOrderDetailDataService>(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(
new[]
{
@ -412,4 +443,37 @@ public class WorkOrderMediaServiceCancellationTests
mediaData.Verify(candidate => candidate.GetAttachmentForReadAsync(10, 1, token), Times.Once);
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);
}
}

View file

@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
@ -78,6 +79,75 @@ public sealed class WorkOrderUpliftControllerTests
Assert.Equal(12, created.Id);
}
[Fact]
public async Task CreateUplift_RequestPermissionDenied_ReturnsExact403Message()
{
var service = new Mock<IWorkOrderUpliftService>();
service.Setup(x => x.CreateAsync(
7,
It.IsAny<CreateWorkOrderUpliftRequestDto>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new UpliftForbiddenException(UpliftForbiddenException.RequestUpliftsDeniedMessage));
var controller = NewController(service, "Dispatcher");
var result = await controller.CreateUplift(
7,
new CreateWorkOrderUpliftRequestDto { Amount = 750m },
CancellationToken.None);
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
var response = forbidden.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().Be(UpliftForbiddenException.RequestUpliftsDeniedMessage);
}
[Fact]
public async Task CreateUplift_UnrelatedForbiddenException_ReturnsSanitized403()
{
var service = new Mock<IWorkOrderUpliftService>();
service.Setup(x => x.CreateAsync(
7,
It.IsAny<CreateWorkOrderUpliftRequestDto>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new UpliftForbiddenException("SECRET-internal-tier-detail"));
var controller = NewController(service, "Dispatcher");
var result = await controller.CreateUplift(
7,
new CreateWorkOrderUpliftRequestDto { Amount = 750m },
CancellationToken.None);
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
var response = forbidden.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().NotContain("SECRET-internal-tier-detail");
response.Message.Should().Contain("reference");
}
[Fact]
public async Task CreateUplift_ConcurrentActiveRequest_ReturnsConflict()
{
var service = new Mock<IWorkOrderUpliftService>();
service.Setup(x => x.CreateAsync(
7,
It.IsAny<CreateWorkOrderUpliftRequestDto>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new UpliftConflictException());
var controller = NewController(service);
var result = await controller.CreateUplift(
7,
new CreateWorkOrderUpliftRequestDto { Amount = 750m, Notes = "Extra labor" },
CancellationToken.None);
var conflict = result.Should().BeOfType<ConflictObjectResult>().Subject;
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
conflict.Value.Should().NotBeNull();
}
[Fact]
public async Task CancelUplift_NotFound_Returns404()
{

View file

@ -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));
}
}

View file

@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
@ -218,6 +219,10 @@ namespace Api.SeaHavenIndustries.Controllers
var result = await _portalService.RequestUpliftAsync(session, id, body?.RequestedNTE ?? 0m, body?.Reason, body?.RequestKey, body?.EvidenceDocumentId, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
}
catch (UpliftConflictException ex)
{
return Conflict(new Response { Status = "Error", Message = ex.Message });
}
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });

View file

@ -164,13 +164,20 @@ namespace Api.SeaHavenIndustries.Controllers
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(new DataResponse { Status = "Success", Data = created });
}
catch (UpliftConflictException ex)
{
return Conflict(new Response { Status = "Error", Message = ex.Message });
}
catch (KeyNotFoundException ex)
{
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Resource not found") });
}
catch (UpliftForbiddenException ex)
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to perform this action") });
var message = ex.Message == UpliftForbiddenException.RequestUpliftsDeniedMessage
? UpliftForbiddenException.RequestUpliftsDeniedMessage
: _logger.Sanitize(ex, "You are not authorized to perform this action");
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = message });
}
catch (InvalidOperationException ex)
{

View file

@ -88,6 +88,10 @@ namespace Api.SeaHavenIndustries.Controllers
string? fileUrl = null;
try
{
// Type first, so an unsupported file always reports UnsupportedMediaType instead
// of being sized under a kind it does not have.
WorkOrderMediaFileRules.EnsureAllowed(file, category);
// SH-116 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(
@ -97,7 +101,6 @@ namespace Api.SeaHavenIndustries.Controllers
throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage);
}
WorkOrderMediaFileRules.EnsureAllowed(file, category);
if (WorkOrderMediaContract.ResolveKind(
WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName)
== WorkOrderMediaContract.UploadKind.Video)

View file

@ -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.
}
}
}

View file

@ -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.")
{
}
}

View file

@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Exceptions;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
@ -336,6 +337,25 @@ namespace SeaHaven.DataServices.Implementation
.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)
{
return ForWorkOrder(workOrderId)
@ -525,7 +545,58 @@ namespace SeaHaven.DataServices.Implementation
public async Task SaveChangesAsync(CancellationToken cancellationToken)
{
await _context.SaveChangesAsync(cancellationToken);
try
{
await _context.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException exception) when (IsActiveDispatchUniqueIndexViolation(exception))
{
throw new UpliftDispatchConflictException();
}
}
private bool IsActiveDispatchUniqueIndexViolation(DbUpdateException exception)
{
var provider = _context.Database.ProviderName;
var databaseException = exception.GetBaseException();
var exceptionType = databaseException.GetType();
if (provider?.Contains("SqlServer", StringComparison.OrdinalIgnoreCase) == true
&& exceptionType.FullName == "Microsoft.Data.SqlClient.SqlException")
{
var number = exceptionType.GetProperty("Number")?.GetValue(databaseException) as int?;
return number is 2601 or 2627
&& IsActiveDispatchIndexViolationMessage(databaseException.Message);
}
if (provider?.Contains("Sqlite", StringComparison.OrdinalIgnoreCase) == true
&& exceptionType.FullName == "Microsoft.Data.Sqlite.SqliteException")
{
var extendedCode = exceptionType.GetProperty("SqliteExtendedErrorCode")?.GetValue(databaseException) as int?;
const string uniqueConstraintPrefix = "UNIQUE constraint failed: ";
var message = databaseException.Message;
var prefixIndex = message.IndexOf(uniqueConstraintPrefix, StringComparison.OrdinalIgnoreCase);
if (extendedCode != 2067 || prefixIndex < 0)
return false;
var columnsStart = prefixIndex + uniqueConstraintPrefix.Length;
var columnsEnd = message.IndexOf('\'', columnsStart);
if (columnsEnd < 0)
columnsEnd = message.Length;
var columns = message[columnsStart..columnsEnd].Trim();
return string.Equals(
columns,
"DispatchUpliftRequests.DispatchId",
StringComparison.OrdinalIgnoreCase);
}
return false;
}
internal static bool IsActiveDispatchIndexViolationMessage(string message)
{
const string activeDispatchIndex = "'IX_DispatchUpliftRequests_DispatchId'";
return message.Contains(activeDispatchIndex, StringComparison.OrdinalIgnoreCase);
}
public async Task<T> ExecuteWorkOrderMutationAsync<T>(

View file

@ -166,6 +166,25 @@ namespace SeaHaven.DataServices.Implementation
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(
string userId, CancellationToken cancellationToken)
{

View file

@ -19,6 +19,10 @@ namespace SeaHaven.DataServices.Interfaces
Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken);
Task<bool> HasPendingAsync(int dispatchId, 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);
// Approval queue read contract: set-based per-work-order exposure totals covering
// auto-approved and admin-approved uplift amounts (pending, rejected,

View file

@ -18,6 +18,7 @@ namespace SeaHaven.DataServices.Interfaces
Task<ApplicationUser?> GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken);
Task UpdateUserAsync(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<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids);
}

View file

@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Api.SeaHavenIndustries.Tests")]
[assembly: InternalsVisibleTo("SeaHavenIndustries.Tests")]

View file

@ -104,6 +104,9 @@ namespace SeaHaven.Services.DTOs
public class UpliftForbiddenException : Exception
{
public const string RequestUpliftsDeniedMessage =
"Your role can't request uplifts on this work order.";
public UpliftForbiddenException(string message) : base(message) { }
}

View 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.")
{
}
}

View file

@ -138,7 +138,7 @@ namespace SeaHaven.Services.Helpers
UploadKind.Document when length > MaxDocumentBytes =>
"Documents must be 50 MB or smaller.",
UploadKind.Unknown when length > MaxVideoBytes =>
"Videos must be 100 MB or smaller.",
"Files must be 100 MB or smaller.",
_ => null
};
}

View file

@ -52,10 +52,6 @@ public sealed class TeamMemberService : ITeamMemberService
if (await _userManager.FindByEmailAsync(email!) is not null)
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 user = new ApplicationUser
{
@ -72,39 +68,44 @@ public sealed class TeamMemberService : ITeamMemberService
PendingRegistrationCreatedDate = now
};
IdentityResult createResult;
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.
return Failure("Email is already in use.");
}
await _userDataService.ExecuteTransactionalAsync(
async transactionCancellationToken =>
{
var roleError = await EnsureRoleAsync(role!);
if (roleError is not null)
throw new TeamMemberCreateException(roleError);
if (!createResult.Succeeded)
return Failure(createResult.Errors.FirstOrDefault()?.Description ?? "Unable to create team member.");
IdentityResult createResult;
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 (!addRoleResult.Succeeded)
{
await _userManager.DeleteAsync(user);
return Failure(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to assign role.");
}
if (!createResult.Succeeded)
throw new TeamMemberCreateException(createResult.Errors.FirstOrDefault()?.Description ?? "Unable to create team member.");
try
{
await _areaDataService.ReplaceAsync(user.Id, areas!, cancellationToken);
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, cancellationToken);
var addRoleResult = await _userManager.AddToRoleAsync(user, role!);
if (!addRoleResult.Succeeded)
throw new TeamMemberCreateException(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to assign role.");
await _areaDataService.ReplaceAsync(user.Id, areas!, transactionCancellationToken);
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, transactionCancellationToken);
},
cancellationToken);
}
catch
catch (TeamMemberCreateException exception)
{
await _userManager.DeleteAsync(user);
throw;
return Failure(exception.Message);
}
return new CreateTeamMemberOutcomeDTO
@ -391,6 +392,13 @@ public sealed class TeamMemberService : ITeamMemberService
private static CreateTeamMemberOutcomeDTO Failure(string error) =>
new() { Success = false, Error = error };
private sealed class TeamMemberCreateException : Exception
{
public TeamMemberCreateException(string error) : base(error)
{
}
}
private static TeamMemberOperationOutcomeDTO OperationFailure(string error) =>
new() { Success = false, Error = error };
}

View file

@ -335,8 +335,12 @@ namespace SeaHaven.Services.Implementation
return UpliftEvidenceDownloadResultDTO.Ok(content, evidence.ContentType ?? "application/octet-stream", evidence.OriginalFileName ?? "evidence");
}
// Admin passes every permission check regardless of stored configuration,
// so the tier-role lists only govern non-Admin approvers.
private bool UserCanApprove(ClaimsPrincipal user, int requiredTier)
{
if (user.IsInRole("Admin")) return true;
var roles = RolesForTier(requiredTier);
foreach (var r in roles)
{

View file

@ -4,6 +4,7 @@ using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
@ -708,7 +709,14 @@ namespace SeaHaven.Services.Implementation
CreatedAt = now
}, cancellationToken);
await _upliftData.SaveChangesAsync(cancellationToken);
try
{
await _upliftData.SaveChangesAsync(cancellationToken);
}
catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException)
{
throw new UpliftConflictException();
}
// Notification is best-effort and persisted separately from workflow state: a failure
// never destroys the actionable request (the lifecycle sweep retries by sentinel).
@ -966,69 +974,79 @@ namespace SeaHaven.Services.Implementation
}
}
// SH-116: the 10-photo / 3-video limit is per work order across the dispatcher and
// vendor surfaces. A new completion version replaces its predecessor, so that one
// does not count against the upload that supersedes it.
if (dispatch.WorkOrderId is int workOrderId)
{
var excluded = resolvedPurpose == VendorDocumentPurpose.Completion
? replacedDocument?.Id
: null;
var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync(
workOrderId, excluded, cancellationToken);
var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync(
workOrderId, cancellationToken);
var countMessage = WorkOrderMediaContract.ValidateCount(kind, attachmentUrls, vendorTypes);
if (countMessage != null)
{
throw new InvalidOperationException(countMessage);
}
}
var version = (latest?.Version ?? 0) + 1;
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 fieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document";
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
async Task<VendorCompletionDocument> PersistAsync(CancellationToken ct)
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
DispatchId = dispatchId,
FieldName = fieldName,
OldValue = isUpliftEvidence || replacedDocument == null
? null
: $"v{replacedDocument.Version}",
NewValue = isUpliftEvidence
? $"evidence {document.OriginalFileName}"
: $"v{version}",
Action = isUpliftEvidence
? "vendor_uplift_evidence_uploaded"
: "vendor_completion_document_uploaded",
ActorType = "vendor",
CreatedAt = now
}, cancellationToken);
await _documentData.SaveChangesAsync(cancellationToken);
// SH-116: the 10-photo / 3-video limit is per work order across the dispatcher and
// vendor surfaces. A new completion version replaces its predecessor, so that one
// does not count against the upload that supersedes it.
if (dispatch.WorkOrderId is int workOrderId)
{
var excluded = resolvedPurpose == VendorDocumentPurpose.Completion
? replacedDocument?.Id
: null;
var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync(
workOrderId, excluded, ct);
var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync(
workOrderId, ct);
var countMessage = WorkOrderMediaContract.ValidateCount(kind, attachmentUrls, vendorTypes);
if (countMessage != null)
{
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);
await _documentStorage.SaveAsync(session.Id, dispatchId, storedFileName, stored, cancellationToken);

View file

@ -249,10 +249,14 @@ namespace SeaHaven.Services.Implementation
_mutationData.TrackNewWorkOrder(workOrder);
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;
dispatch.WorkOrderId = workOrder.Id;
workOrder.PrimaryDispatchId ??= dispatch.Id;
dispatch.WorkOrderId ??= workOrder.Id;
}

View file

@ -16,17 +16,20 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderDetailDataService _detailData;
private readonly IWorkOrderAuditService _auditService;
private readonly IFileStoragePort _fileStorage;
private readonly IUpliftDataService _upliftData;
public WorkOrderMediaService(
IWorkOrderMediaDataService mediaData,
IWorkOrderDetailDataService detailData,
IWorkOrderAuditService auditService,
IFileStoragePort fileStorage)
IFileStoragePort fileStorage,
IUpliftDataService upliftData)
{
_mediaData = mediaData;
_detailData = detailData;
_auditService = auditService;
_fileStorage = fileStorage;
_upliftData = upliftData;
}
public async Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(
@ -153,25 +156,34 @@ namespace SeaHaven.Services.Implementation
};
}
await EnsureWithinMediaCountsAsync(workOrderId, fileUrl, cancellationToken);
var attachment = new WorkOrderAttachments
{
WorkorderId = workOrderId,
Attachments = fileUrl,
Category = category.HasValue ? resolvedCategory : null,
CreatedDate = DateTime.UtcNow,
createdby = actorId
};
_mediaData.TrackAttachment(attachment);
await _auditService.StageFieldChangedAsync(
// SH-116 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.
var attachment = await _upliftData.ExecuteWorkOrderMutationAsync(
workOrderId,
"MediaCategory",
null,
FormatMediaAuditValue(null, (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString()),
actorId);
await SaveMediaAsync(cancellationToken);
async ct =>
{
await EnsureWithinMediaCountsAsync(workOrderId, fileUrl, ct);
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
{
@ -457,6 +469,7 @@ namespace SeaHaven.Services.Implementation
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".heic" => "image/heic",
".mp4" => "video/mp4",
".mov" => "video/quicktime",
".pdf" => "application/pdf",

View file

@ -4,6 +4,7 @@ using Data.SeaHavenIndustries.Enums;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
@ -19,6 +20,8 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderDetailDataService _detailData;
private readonly IWorkOrderAccountResolver _accountResolver;
private readonly IUserDataService _userData;
private readonly ITeamPermissionOverrideDataService _permissionOverrideData;
private readonly ITeamPermissionPolicy _permissionPolicy;
private readonly TimeProvider _timeProvider;
private readonly ApprovalsOptions _approvalsOptions;
@ -28,6 +31,8 @@ namespace SeaHaven.Services.Implementation
IWorkOrderDetailDataService detailData,
IWorkOrderAccountResolver accountResolver,
IUserDataService userData,
ITeamPermissionOverrideDataService permissionOverrideData,
ITeamPermissionPolicy permissionPolicy,
TimeProvider timeProvider,
IOptions<ApprovalsOptions> approvalsOptions)
{
@ -36,6 +41,8 @@ namespace SeaHaven.Services.Implementation
_detailData = detailData;
_accountResolver = accountResolver;
_userData = userData;
_permissionOverrideData = permissionOverrideData;
_permissionPolicy = permissionPolicy;
_timeProvider = timeProvider;
_approvalsOptions = approvalsOptions.Value;
}
@ -61,6 +68,8 @@ namespace SeaHaven.Services.Implementation
if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken))
return null;
await EnsureCanRequestUpliftAsync(user, cancellationToken);
if (request.Amount <= 0)
throw new InvalidOperationException("Uplift amount must be greater than zero");
@ -69,17 +78,49 @@ namespace SeaHaven.Services.Implementation
var notes = request.Notes?.Trim() ?? "";
var accountFilter = _accountResolver.ResolveAccountFilter(user);
return await _upliftData.ExecuteWorkOrderMutationAsync(
workOrderId,
ct => CreateLockedAsync(
try
{
return await _upliftData.ExecuteWorkOrderMutationAsync(
workOrderId,
request.Amount,
notes,
userId,
requesterName,
accountFilter,
ct),
cancellationToken);
ct => CreateLockedAsync(
workOrderId,
request.Amount,
notes,
userId,
requesterName,
accountFilter,
ct),
cancellationToken);
}
catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException)
{
throw new UpliftConflictException();
}
}
private async Task EnsureCanRequestUpliftAsync(
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
var permissionUser = string.IsNullOrWhiteSpace(userId)
? null
: await _permissionOverrideData.GetUserAsync(userId, cancellationToken);
if (permissionUser is null)
{
throw new UpliftForbiddenException(
UpliftForbiddenException.RequestUpliftsDeniedMessage);
}
if (!_permissionPolicy.IsAllowed(
permissionUser.RoleName,
TeamPermissionKeys.RequestUplifts,
permissionUser.Overrides))
{
throw new UpliftForbiddenException(
UpliftForbiddenException.RequestUpliftsDeniedMessage);
}
}
private async Task<WorkOrderUpliftDto?> CreateLockedAsync(
@ -104,24 +145,16 @@ namespace SeaHaven.Services.Implementation
$"Cannot create an uplift on a '{workOrder.LifecycleStatus}' work order");
}
Dispatch? dispatch;
if (workOrder.PrimaryDispatchId is int primaryDispatchId)
{
dispatch = await _dispatchData.GetByIdAsync(primaryDispatchId);
if (dispatch == null)
throw new KeyNotFoundException("Dispatch not found");
}
else
{
dispatch = (await _dispatchData.GetByWorkOrderIdAsync(workOrderId, cancellationToken))
.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");
}
// SH-393: write to a dispatch the work order's uplift reads resolve back to it.
// 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
// consumed its allowance.
var dispatch = await _upliftData.GetUpliftDispatchForWorkOrderAsync(
workOrderId,
workOrder.PrimaryDispatchId,
cancellationToken);
if (dispatch == null)
throw new InvalidOperationException("Work order has no primary dispatch for uplift requests");
if (IsTerminalForUplift(dispatch.Status))
throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch");

View 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);
}
}

View file

@ -9,6 +9,117 @@ namespace SeaHavenIndustries.Tests;
public sealed class UpliftDataServiceTransactionTests
{
[Theory]
[InlineData("Cannot insert duplicate key row with unique index 'IX_DispatchUpliftRequests_DispatchId'.", true)]
[InlineData("Cannot insert duplicate key row with unique index 'IX_DispatchUpliftRequests_DispatchId_RequestKey'.", false)]
public void IsActiveDispatchIndexViolationMessage_MatchesOnlyTheActiveDispatchIndex(
string message,
bool expected)
{
Assert.Equal(expected, UpliftDataService.IsActiveDispatchIndexViolationMessage(message));
}
[Fact]
public async Task SaveChangesAsync_ActiveUpliftOnSameDispatch_MapsConcurrentInsertConflict()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using var context = new ApplicationDbContext(options);
await context.Database.EnsureCreatedAsync();
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
DispatchNumber = "DIS-10",
Status = "Scheduled",
});
await context.SaveChangesAsync();
var data = new UpliftDataService(context);
await data.StageAsync(new DispatchUpliftRequest
{
DispatchId = 10,
RequestedNTE = 1500m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending",
}, CancellationToken.None);
await data.SaveChangesAsync(CancellationToken.None);
// The database index is the final guard when competing requests pass
// their dispatch-scoped active-request checks.
var exception = await Assert.ThrowsAsync<SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException>(
() => data.ExecuteWorkOrderMutationAsync(
2,
async cancellationToken =>
{
await data.StageAsync(new DispatchUpliftRequest
{
DispatchId = 10,
RequestedNTE = 1700m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending",
}, cancellationToken);
await data.SaveChangesAsync(cancellationToken);
return true;
},
CancellationToken.None));
Assert.Equal("An active uplift request already exists for this dispatch.", exception.Message);
Assert.Equal(1, await context.DispatchUpliftRequests.CountAsync());
}
[Fact]
public async Task SaveChangesAsync_RequestKeyConflict_IsNotMappedToActiveDispatchConflict()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using var context = new ApplicationDbContext(options);
await context.Database.EnsureCreatedAsync();
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
DispatchNumber = "DIS-10",
Status = "Scheduled",
});
await context.SaveChangesAsync();
var data = new UpliftDataService(context);
await data.StageAsync(new DispatchUpliftRequest
{
DispatchId = 10,
RequestKey = "same-key",
RequestedNTE = 1500m,
Status = "Approved",
RequiredTier = 1,
NotificationStatus = "Sent",
}, CancellationToken.None);
await data.SaveChangesAsync(CancellationToken.None);
await data.StageAsync(new DispatchUpliftRequest
{
DispatchId = 10,
RequestKey = "same-key",
RequestedNTE = 1700m,
Status = "Approved",
RequiredTier = 1,
NotificationStatus = "Sent",
}, CancellationToken.None);
await Assert.ThrowsAsync<DbUpdateException>(
() => data.SaveChangesAsync(CancellationToken.None));
}
[Fact]
public async Task ExecuteWorkOrderMutationAsync_ReleasesGate_WhenTransactionInitializationFails()
{

View file

@ -1,5 +1,6 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Implementation;
@ -203,6 +204,8 @@ public class WorkOrderBoardCancelServiceTests
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts, new PassThroughUpliftData());
@ -264,6 +267,8 @@ public class WorkOrderBoardCancelServiceTests
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var cancel = new WorkOrderBoardCancelService(
@ -300,6 +305,13 @@ public class WorkOrderBoardCancelServiceTests
FirstName = "Alex",
LastName = "Dispatcher",
});
var adminRole = new IdentityRole("Admin");
seed.Roles.Add(adminRole);
seed.UserRoles.Add(new IdentityUserRole<string>
{
UserId = "dispatcher-1",
RoleId = adminRole.Id,
});
seed.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
seed.Dispatches.Add(new Dispatch
{
@ -332,6 +344,8 @@ public class WorkOrderBoardCancelServiceTests
new WorkOrderDetailDataService(createContext),
WorkOrderAccountTestHelpers.Resolver(createContext),
new UserDataService(createContext),
new TeamPermissionOverrideDataService(createContext),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var boardData = new WorkOrderBoardDataService(cancelContext);
@ -345,6 +359,8 @@ public class WorkOrderBoardCancelServiceTests
new WorkOrderDetailDataService(cancelContext),
WorkOrderAccountTestHelpers.Resolver(cancelContext),
new UserDataService(cancelContext),
new TeamPermissionOverrideDataService(cancelContext),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var cancel = new WorkOrderBoardCancelService(
@ -442,6 +458,7 @@ public class WorkOrderBoardCancelServiceTests
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> 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<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken) => throw new NotSupportedException();

View file

@ -134,6 +134,72 @@ public class WorkOrderBoardCreateRelationalTests
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
{
public SqliteBoardTestDbContext(DbContextOptions<ApplicationDbContext> options)

View file

@ -41,7 +41,8 @@ public class WorkOrderCompletedSelectiveLockTests
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit,
new TestFileStoragePort());
new TestFileStoragePort(),
new UpliftDataService(context));
return (context, service);
}

View file

@ -176,7 +176,8 @@ public class WorkOrderMediaConcurrencyRelationalTests
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit,
new TestFileStoragePort());
new TestFileStoragePort(),
new UpliftDataService(context));
}
private static async Task<string> LoadVersionAsync(ApplicationDbContext context, int workOrderId)

View file

@ -812,7 +812,8 @@ public class WorkOrderMediaServiceTests
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit,
fileStorage ?? new TestFileStoragePort());
fileStorage ?? new TestFileStoragePort(),
new UpliftDataService(context));
return (context, service);
}
@ -1343,6 +1344,60 @@ public class WorkOrderMediaServiceTests
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()
{

View file

@ -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());
}
}

View file

@ -1,9 +1,11 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
@ -37,6 +39,8 @@ public sealed class WorkOrderUpliftServiceTests
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(NewOptions()));
}
@ -47,6 +51,7 @@ public sealed class WorkOrderUpliftServiceTests
private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync(
ApplicationDbContext context,
WorkOrderType type = WorkOrderType.PM,
string role = "Admin",
int? primaryDispatchId = 10)
{
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
@ -57,6 +62,13 @@ public sealed class WorkOrderUpliftServiceTests
FirstName = "Alex",
LastName = "Dispatcher",
});
var identityRole = new IdentityRole(role);
context.Roles.Add(identityRole);
context.UserRoles.Add(new IdentityUserRole<string>
{
UserId = "dispatcher-1",
RoleId = identityRole.Id,
});
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
@ -124,6 +136,22 @@ public sealed class WorkOrderUpliftServiceTests
Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
}
[Fact]
public async Task CreateAsync_CurrentDatabaseAdmin_RemainsUnrestricted()
{
await using var context = CreateContext();
var (workOrder, _) = await SeedWorkOrderAsync(context);
var service = NewService(context);
var created = await service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, "Scheduler"),
CancellationToken.None);
Assert.NotNull(created);
}
[Fact]
public async Task CreateAsync_WithoutPrimaryDispatch_UsesWorkOrderDispatch()
{
@ -174,6 +202,109 @@ public sealed class WorkOrderUpliftServiceTests
Assert.Equal(workOrder.Id, audit.WorkOrderId);
}
[Fact]
public async Task CreateAsync_StaleAdminClaim_DeniesWhenDatabaseRoleIsNotAdmin()
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context, role: "Scheduler");
var service = NewService(context);
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(() => service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
Dispatcher(),
CancellationToken.None));
Assert.Equal(UpliftForbiddenException.RequestUpliftsDeniedMessage, exception.Message);
Assert.Empty(context.DispatchUpliftRequests);
Assert.Equal(1000m, dispatch.NTEAmount);
}
[Fact]
public async Task CreateAsync_MissingDatabaseUser_DeniesEvenWithAdminClaim()
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
context.UserRoles.RemoveRange(context.UserRoles.Where(userRole => userRole.UserId == "dispatcher-1"));
context.Users.Remove(context.Users.Single(user => user.Id == "dispatcher-1"));
await context.SaveChangesAsync();
var service = NewService(context);
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(() => service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
Dispatcher("dispatcher-1"),
CancellationToken.None));
Assert.Equal(UpliftForbiddenException.RequestUpliftsDeniedMessage, exception.Message);
Assert.Empty(context.DispatchUpliftRequests);
Assert.Equal(1000m, dispatch.NTEAmount);
}
[Theory]
[InlineData("Dispatcher", true)]
[InlineData("Scheduler", false)]
public async Task CreateAsync_UsesRoleDefaultForRequestPermission(string role, bool allowed)
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context, role: role);
var service = NewService(context);
var act = () => service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, role),
CancellationToken.None);
if (allowed)
{
Assert.NotNull(await act());
return;
}
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(act);
Assert.Equal("Your role can't request uplifts on this work order.", exception.Message);
Assert.Empty(context.DispatchUpliftRequests);
Assert.Equal(1000m, dispatch.NTEAmount);
}
[Theory]
[InlineData("Scheduler", UserPermissionState.Allow, true)]
[InlineData("Dispatcher", UserPermissionState.Deny, false)]
public async Task CreateAsync_AppliesExplicitRequestPermissionOverride(
string role,
UserPermissionState overrideState,
bool allowed)
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context, role: role);
context.UserPermissionOverrides.Add(new UserPermissionOverride
{
UserId = "dispatcher-1",
PermissionKey = TeamPermissionKeys.RequestUplifts,
State = overrideState,
});
await context.SaveChangesAsync();
var service = NewService(context);
var act = () => service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, role),
CancellationToken.None);
if (allowed)
{
Assert.NotNull(await act());
return;
}
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(act);
Assert.Equal("Your role can't request uplifts on this work order.", exception.Message);
Assert.Empty(context.DispatchUpliftRequests);
Assert.Equal(1000m, dispatch.NTEAmount);
}
[Fact]
public async Task CreateAsync_PmAmountAboveCap_CreatesPendingRequest()
{
@ -289,7 +420,21 @@ public sealed class WorkOrderUpliftServiceTests
{
await using var context = CreateContext();
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
{
Id = 100,