using Data.SeaHavenIndustries; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; using SeaHaven.DataServices.Implementation; using SeaHaven.DataServices.Interfaces; 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; // Repository-owned behavior tests for the SH-101 uplift approval workflow. These exercise // the real service + data-service layers against an in-memory DbContext so that state // transitions, audit correctness, idempotency, scoping, and the internal evidence // download are validated without recreating infrastructure or depending on SQL Server. public sealed class UpliftWorkflowTests { private const string Token = "uplift-workflow-token"; private static ApplicationDbContext NewContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new ApplicationDbContext(options); } private static ApprovalsOptions NewOptions() => new() { UpliftTier1MaxUsd = 2500m, Tier1Roles = new[] { "Approver" }, Tier2Roles = new[] { "Manager" }, Tier1NotificationRecipients = new[] { "tier1@example.com" }, Tier2NotificationRecipients = new[] { "tier2@example.com" }, EscalationRecipients = new[] { "escalate@example.com" } }; private static ClaimsPrincipal UserWithRoles(params string[] roles) { var claims = new List { new(ClaimTypes.NameIdentifier, "user-42") }; claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r))); return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); } private sealed class FakeDocumentStorage : IVendorDocumentStoragePort { private readonly Dictionary _files = new(StringComparer.Ordinal); public Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken) { using var ms = new MemoryStream(); content.CopyTo(ms); _files[Key(vendorId, dispatchId, storedFileName)] = ms.ToArray(); return Task.CompletedTask; } public Stream OpenRead(int vendorId, int dispatchId, string storedFileName) { var bytes = _files[Key(vendorId, dispatchId, storedFileName)]; return new MemoryStream(bytes, writable: false); } public void Delete(int vendorId, int dispatchId, string storedFileName) => _files.Remove(Key(vendorId, dispatchId, storedFileName)); private static string Key(int vendorId, int dispatchId, string storedFileName) => $"{vendorId}/{dispatchId}/{storedFileName}"; } private sealed class FakeEmailSender : IEmailSender { private readonly bool _deliver; public int Calls; public FakeEmailSender(bool deliver) => _deliver = deliver; public Task SendEmailAsync(string emailTo, string subject, string body) { Calls++; return Task.FromResult(_deliver); } } private static async Task<(Vendor Vendor, WorkOrder WorkOrder, Dispatch Dispatch)> SeedAsync( ApplicationDbContext context, string status = "Completed", decimal? nte = 1000m) { 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 = status, NTEAmount = nte, DispatchNumber = "DIS-1" }; context.Dispatches.Add(dispatch); context.VendorAccessTokens.Add(new VendorAccessToken { VendorId = vendor.Id, Token = Token, IssuedAt = DateTime.UtcNow, ExpiresAt = DateTime.UtcNow.AddDays(1) }); await context.SaveChangesAsync(); return (vendor, workOrder, dispatch); } private static VendorCompletionDocument EvidenceDocument( int vendorId, int dispatchId, int workOrderId, string scanStatus = "Passed", string purpose = "UpliftEvidence") => new() { VendorId = vendorId, DispatchId = dispatchId, WorkOrderId = workOrderId, OriginalFileName = "invoice.pdf", StoredFileName = "evidence.bin", ContentType = "application/pdf", SizeBytes = 4, ScanStatus = scanStatus, ReviewStatus = scanStatus == "Passed" ? "Approved" : "Processing", Purpose = purpose, Version = 1 }; private static UpliftService NewUpliftService( ApplicationDbContext context, IVendorDocumentStoragePort storage) => new(new UpliftDataService(context), new DispatchDataService(context), storage, TimeProvider.System, Microsoft.Extensions.Options.Options.Create(NewOptions())); private static VendorPortalService NewPortalService( ApplicationDbContext context, IEmailSender emailSender, IVendorDocumentStoragePort? storage = null, IUpliftDataService? upliftData = null) { var vendorData = new VendorDataService(context); var tokenService = new VendorPortalTokenService(vendorData, Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions())); storage ??= new FakeDocumentStorage(); var commentData = new Mock(); commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); return new VendorPortalService( tokenService, new DispatchDataService(context), upliftData ?? new UpliftDataService(context), commentData.Object, Mock.Of(), emailSender, new VendorDocumentDataService(context), storage, Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), Microsoft.Extensions.Options.Options.Create(NewOptions()), Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions()), TimeProvider.System); } // --- NoApprovalRequired: no mutation, no request row, audit-only --- [Fact] public async Task RequestUplift_NoApprovalRequired_WhenRequestedNteAtOrBelowCurrent_DoesNotMutate() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); var service = NewPortalService(context, new FakeEmailSender(deliver: true)); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); var result = await service.RequestUpliftAsync(session!, dispatch.Id, 1000m, "ignored", null, null, CancellationToken.None); result.NoApprovalRequired.Should().BeTrue(); result.Id.Should().Be(0); result.Status.Should().Be(UpliftStatus.NoApprovalRequired); context.DispatchUpliftRequests.Should().BeEmpty(); context.Dispatches.Single().NTEAmount.Should().Be(1000m); context.WorkOrderAuditLogs.Should().ContainSingle(a => a.Action == "uplift_no_approval_required"); } // --- Tier edge --- [Fact] public async Task RequestUplift_Tier1_WhenDeltaEqualsTier1Max() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); await context.SaveChangesAsync(); var service = NewPortalService(context, new FakeEmailSender(deliver: true)); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); var result = await service.RequestUpliftAsync(session!, dispatch.Id, 3500m, "reason", null, 1, CancellationToken.None); result.RequiredTier.Should().Be(1, "delta 2500 == tier1 max is still tier 1"); } [Fact] public async Task RequestUplift_Tier2_WhenDeltaExceedsTier1Max() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); await context.SaveChangesAsync(); var service = NewPortalService(context, new FakeEmailSender(deliver: true)); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); var result = await service.RequestUpliftAsync(session!, dispatch.Id, 3500.01m, "reason", null, 1, CancellationToken.None); 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(); upliftData.Setup(x => x.HasActiveAsync(dispatch.Id, It.IsAny())) .ReturnsAsync(false); upliftData.Setup(x => x.StageAsync(It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); upliftData.Setup(x => x.SaveChangesAsync(It.IsAny())) .ThrowsAsync(new SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException()); upliftData.Setup(x => x.ExecuteWorkOrderMutationAsync( It.IsAny(), It.IsAny>>(), It.IsAny())) .Returns((int _, Func> work, CancellationToken ct) => work(ct)); 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(); } // --- Evidence scan + cross-vendor/dispatch scoping --- [Fact] public async Task RequestUplift_EvidenceScanNotPassed_Throws() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id, scanStatus: "Pending")); await context.SaveChangesAsync(); var service = NewPortalService(context, new FakeEmailSender(deliver: true)); 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(); context.DispatchUpliftRequests.Should().BeEmpty(); } [Fact] public async Task RequestUplift_EvidenceFromAnotherVendor_Throws() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); var otherVendor = new Vendor { CompanyName = "Other", IsActive = true }; var otherWorkOrder = new WorkOrder { WorkerOrderTitle = "Other" }; context.AddRange(otherVendor, otherWorkOrder); await context.SaveChangesAsync(); var otherDispatch = new Dispatch { VendorId = otherVendor.Id, WorkOrderId = otherWorkOrder.Id, Status = "Completed" }; context.Dispatches.Add(otherDispatch); context.VendorCompletionDocuments.Add(EvidenceDocument(otherVendor.Id, otherDispatch.Id, otherWorkOrder.Id)); await context.SaveChangesAsync(); var service = NewPortalService(context, new FakeEmailSender(deliver: true)); 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(); } // --- RequestKey identical replay / mismatch (incl. EvidenceDocumentId) --- [Fact] public async Task RequestUplift_RequestKey_IdenticalReplay_ReturnsSameRequest() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); await context.SaveChangesAsync(); var service = NewPortalService(context, new FakeEmailSender(deliver: true)); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); var first = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "need parts", "key-1", 1, CancellationToken.None); var replay = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "need parts", "key-1", 1, CancellationToken.None); replay.Id.Should().Be(first.Id); replay.IdempotentReplay.Should().BeTrue(); context.DispatchUpliftRequests.Should().ContainSingle(); } [Fact] public async Task RequestUplift_RequestKey_MismatchedEvidence_Throws() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); context.VendorCompletionDocuments.AddRange( EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id), new VendorCompletionDocument { VendorId = vendor.Id, DispatchId = dispatch.Id, WorkOrderId = workOrder.Id, OriginalFileName = "second.pdf", StoredFileName = "second.bin", ContentType = "application/pdf", SizeBytes = 4, ScanStatus = "Passed", ReviewStatus = "Approved", Purpose = "UpliftEvidence", Version = 2 }); await context.SaveChangesAsync(); var firstEvidenceId = 1; var secondEvidenceId = 2; var service = NewPortalService(context, new FakeEmailSender(deliver: true)); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "need parts", "key-1", firstEvidenceId, CancellationToken.None); var act = () => service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "need parts", "key-1", secondEvidenceId, CancellationToken.None); await act.Should().ThrowAsync(); } [Fact] public async Task RequestUplift_RequestKey_MismatchedAmount_Throws() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); await context.SaveChangesAsync(); var service = NewPortalService(context, new FakeEmailSender(deliver: true)); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "need parts", "key-1", 1, CancellationToken.None); var act = () => service.RequestUpliftAsync(session!, dispatch.Id, 1600m, "need parts", "key-1", 1, CancellationToken.None); await act.Should().ThrowAsync(); } // --- Notification error is separate from workflow state --- [Fact] public async Task RequestUplift_NotificationDeliveryFailure_LeavesRequestPendingButRecordsError() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); await context.SaveChangesAsync(); var service = NewPortalService(context, new FakeEmailSender(deliver: false)); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); var result = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "need parts", null, 1, CancellationToken.None); var req = context.DispatchUpliftRequests.Single(); req.Status.Should().Be(UpliftStatus.Pending); req.NotificationStatus.Should().Be(UpliftNotificationStatus.Error); result.Status.Should().Be(UpliftStatus.Pending); result.Id.Should().NotBe(0); } // --- SH-98 terminal dispatch guard (Refused) --- [Fact] public async Task RequestUplift_RefusedDispatch_Throws() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, status: "Refused", nte: 1000m); var service = NewPortalService(context, new FakeEmailSender(deliver: true)); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); var act = () => service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, null, CancellationToken.None); await act.Should().ThrowAsync(); } // --- revise / withdraw / request-changes state transitions --- [Fact] public async Task Revise_OnChangesRequested_ReturnsToPending_AndResetsDecision() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); await context.SaveChangesAsync(); var service = NewPortalService(context, new FakeEmailSender(deliver: true)); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); var created = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None); var req = context.DispatchUpliftRequests.Single(); req.Status = UpliftStatus.ChangesRequested; req.DecisionNote = "lower please"; req.DecidedAt = DateTime.UtcNow; await context.SaveChangesAsync(); var revised = await service.ReviseUpliftAsync(session!, dispatch.Id, created.Id, 1700m, "revised reason", 1, CancellationToken.None); revised.Status.Should().Be(UpliftStatus.Pending); var after = context.DispatchUpliftRequests.Single(); after.Status.Should().Be(UpliftStatus.Pending); after.DecisionNote.Should().BeNull(); after.DecidedAt.Should().BeNull(); after.RequestedNTE.Should().Be(1700m); context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_revised"); } [Fact] public async Task Revise_WorkOrderRequest_IsRefusedAsNotFound_AndLeavesTheRowUnchanged() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 600m); context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); // Raised from the work order by an internal user and sent back for changes: // RequestedNTE holds the 90 increase, not a total. var workOrderRequest = new DispatchUpliftRequest { DispatchId = dispatch.Id, CurrentNTE = 600m, RequestedNTE = 90m, Status = UpliftStatus.ChangesRequested, RequiredTier = 1, VendorReason = "Extra parts", RequestedByVendorName = "Alex Dispatcher", NotificationStatus = "Sent", createdby = "dispatcher-1", CreatedDate = new DateTime(2026, 3, 1), }; context.DispatchUpliftRequests.Add(workOrderRequest); await context.SaveChangesAsync(); var service = NewPortalService(context, new FakeEmailSender(deliver: true)); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); var act = () => service.ReviseUpliftAsync(session!, dispatch.Id, workOrderRequest.Id, 900m, "vendor total", 1, CancellationToken.None); await act.Should().ThrowAsync(); var after = context.DispatchUpliftRequests.AsNoTracking().Single(u => u.Id == workOrderRequest.Id); after.Status.Should().Be(UpliftStatus.ChangesRequested); after.RequestedNTE.Should().Be(90m); after.CurrentNTE.Should().Be(600m); after.VendorReason.Should().Be("Extra parts"); after.createdby.Should().Be("dispatcher-1"); context.WorkOrderAuditLogs.Should().NotContain(a => a.Action == "uplift_revised"); } [Fact] public async Task Withdraw_Pending_TransitionsToWithdrawn_SetsDecidedAt() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); await context.SaveChangesAsync(); var service = NewPortalService(context, new FakeEmailSender(deliver: true)); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); var created = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None); var beforeDecidedAt = context.DispatchUpliftRequests.Single().DecidedAt; var result = await service.WithdrawUpliftAsync(session!, dispatch.Id, created.Id, CancellationToken.None); result.Status.Should().Be(UpliftStatus.Withdrawn); var after = context.DispatchUpliftRequests.Single(); after.Status.Should().Be(UpliftStatus.Withdrawn); after.DecidedAt.Should().NotBeNull(); context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_withdraw" && a.OldValue == UpliftStatus.Pending); } [Fact] public async Task Withdraw_AlreadyApproved_Throws() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); await context.SaveChangesAsync(); var service = NewPortalService(context, new FakeEmailSender(deliver: true)); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); var created = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None); context.DispatchUpliftRequests.Single().Status = UpliftStatus.Approved; await context.SaveChangesAsync(); var act = () => service.WithdrawUpliftAsync(session!, dispatch.Id, created.Id, CancellationToken.None); await act.Should().ThrowAsync(); } // Raised from the work order by an internal user: createdby is set and RequestedNTE // holds the increase. Vendor sessions have no identity user, so they never set it. private static DispatchUpliftRequest WorkOrderRequest(int dispatchId, string status) => new() { DispatchId = dispatchId, CurrentNTE = 600m, RequestedNTE = 90m, Status = status, RequiredTier = 1, VendorReason = "Extra parts", RequestedByVendorName = "Alex Dispatcher", NotificationStatus = "Sent", createdby = "dispatcher-1", CreatedDate = new DateTime(2026, 3, 1), }; [Theory] [InlineData("Pending", "withdraw")] [InlineData("ChangesRequested", "withdraw")] [InlineData("Pending", "cancel")] [InlineData("ChangesRequested", "cancel")] public async Task Withdraw_WorkOrderRequest_IsRefusedAsNotFound_AndLeavesTheRowUnchanged(string status, string route) { using var context = NewContext(); var (_, _, dispatch) = await SeedAsync(context, nte: 600m); var workOrderRequest = WorkOrderRequest(dispatch.Id, status); context.DispatchUpliftRequests.Add(workOrderRequest); await context.SaveChangesAsync(); var service = NewPortalService(context, new FakeEmailSender(deliver: true)); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); Func act = route == "cancel" ? () => service.CancelUpliftRequestAsync(session!, dispatch.Id, workOrderRequest.Id, CancellationToken.None) : () => service.WithdrawUpliftAsync(session!, dispatch.Id, workOrderRequest.Id, CancellationToken.None); await act.Should().ThrowAsync(); var after = context.DispatchUpliftRequests.AsNoTracking().Single(u => u.Id == workOrderRequest.Id); after.Status.Should().Be(status); after.DecidedAt.Should().BeNull(); after.RequestedNTE.Should().Be(90m); after.createdby.Should().Be("dispatcher-1"); context.WorkOrderAuditLogs.Should().NotContain(a => a.Action == "uplift_withdraw" || a.Action == "uplift_cancel"); } [Fact] public async Task Cancel_VendorRaisedChangesRequested_StillWithdraws() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); await context.SaveChangesAsync(); var service = NewPortalService(context, new FakeEmailSender(deliver: true)); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); var created = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None); context.DispatchUpliftRequests.Single().Status = UpliftStatus.ChangesRequested; await context.SaveChangesAsync(); var result = await service.CancelUpliftRequestAsync(session!, dispatch.Id, created.Id, CancellationToken.None); result.Status.Should().Be(UpliftStatus.Withdrawn); context.DispatchUpliftRequests.AsNoTracking().Single().Status.Should().Be(UpliftStatus.Withdrawn); context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_cancel" && a.OldValue == UpliftStatus.ChangesRequested); } [Fact] public async Task DispatchDetail_ReportsRaisedByVendor_PerCreationPath() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); await context.SaveChangesAsync(); var service = NewPortalService(context, new FakeEmailSender(deliver: true)); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); var vendorRaised = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None); await service.WithdrawUpliftAsync(session!, dispatch.Id, vendorRaised.Id, CancellationToken.None); var workOrderRequest = WorkOrderRequest(dispatch.Id, UpliftStatus.Pending); context.DispatchUpliftRequests.Add(workOrderRequest); await context.SaveChangesAsync(); var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); detail!.UpliftRequests.Should().HaveCount(2); detail.UpliftRequests.Single(u => u.Id == vendorRaised.Id).RaisedByVendor.Should().BeTrue(); detail.UpliftRequests.Single(u => u.Id == workOrderRequest.Id).RaisedByVendor.Should().BeFalse(); } [Fact] public async Task RequestChanges_Pending_TransitionsToChangesRequested_AuditsOldValue() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { DispatchId = dispatch.Id, CurrentNTE = 1000m, RequestedNTE = 1500m, VendorReason = "reason", Status = UpliftStatus.Pending, RequiredTier = 1, CreatedDate = DateTime.UtcNow }); await context.SaveChangesAsync(); var service = NewUpliftService(context, new FakeDocumentStorage()); var result = await service.RequestChangesAsync(UserWithRoles("Approver"), 1, "provide quote", CancellationToken.None); result.Status.Should().Be(UpliftStatus.ChangesRequested); context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_changes_requested" && a.OldValue == UpliftStatus.Pending && a.NewValue == UpliftStatus.ChangesRequested); } // --- Fix 1: reject/deny audit OldValue captures the pre-transition canonical status --- [Fact] public async Task Reject_Pending_AuditOldValueIsPending_NotRejected() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { DispatchId = dispatch.Id, CurrentNTE = 1000m, RequestedNTE = 1500m, VendorReason = "reason", Status = UpliftStatus.Pending, RequiredTier = 1, CreatedDate = DateTime.UtcNow }); await context.SaveChangesAsync(); var service = NewUpliftService(context, new FakeDocumentStorage()); await service.RejectAsync(UserWithRoles("Approver"), 1, "too high", CancellationToken.None); context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_reject" && a.OldValue == UpliftStatus.Pending && a.NewValue == UpliftStatus.Rejected); } [Fact] public async Task Deny_Pending_AuditOldValueIsPending_NotRejected() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { DispatchId = dispatch.Id, CurrentNTE = 1000m, RequestedNTE = 1500m, VendorReason = "reason", Status = UpliftStatus.Pending, RequiredTier = 1, CreatedDate = DateTime.UtcNow }); await context.SaveChangesAsync(); var service = NewUpliftService(context, new FakeDocumentStorage()); await service.DenyAsync(UserWithRoles("Approver"), 1, "too high", CancellationToken.None); context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_deny" && a.OldValue == UpliftStatus.Pending && a.NewValue == UpliftStatus.Rejected); } [Fact] public async Task Approve_Pending_UpdatesNte_AndTransitionsToApproved() { using var context = NewContext(); var (_, _, dispatch) = await SeedAsync(context, nte: 1000m); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { DispatchId = dispatch.Id, CurrentNTE = 1000m, RequestedNTE = 1800m, VendorReason = "reason", Status = UpliftStatus.Pending, RequiredTier = 1, CreatedDate = DateTime.UtcNow }); await context.SaveChangesAsync(); var service = NewUpliftService(context, new FakeDocumentStorage()); var result = await service.ApproveAsync(UserWithRoles("Approver"), 1, "ok", CancellationToken.None); result.Status.Should().Be(UpliftStatus.Approved); context.Dispatches.Single().NTEAmount.Should().Be(1800m); context.DispatchUpliftRequests.Single().DecidedAt.Should().NotBeNull(); } [Fact] public async Task Approve_RefusedDispatch_Throws() { using var context = NewContext(); var (_, _, dispatch) = await SeedAsync(context, status: "Refused", nte: 1000m); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { DispatchId = dispatch.Id, CurrentNTE = 1000m, RequestedNTE = 1800m, VendorReason = "reason", Status = UpliftStatus.Pending, RequiredTier = 1, CreatedDate = DateTime.UtcNow }); await context.SaveChangesAsync(); var service = NewUpliftService(context, new FakeDocumentStorage()); var act = () => service.ApproveAsync(UserWithRoles("Approver"), 1, "ok", CancellationToken.None); await act.Should().ThrowAsync(); context.Dispatches.Single().NTEAmount.Should().Be(1000m); } // --- Fix 6: expiry sets DecidedAt and preserves auditable transition --- [Fact] public async Task ExpireDue_SetsStatusExpired_AndDecidedAt_AndAuditsTransition() { using var context = NewContext(); var (_, _, dispatch) = await SeedAsync(context, status: "In Progress", nte: 1000m); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { DispatchId = dispatch.Id, CurrentNTE = 1000m, RequestedNTE = 1500m, VendorReason = "reason", Status = UpliftStatus.Pending, RequiredTier = 1, ExpiresAt = DateTime.UtcNow.AddHours(-1), CreatedDate = DateTime.UtcNow.AddHours(-2) }); await context.SaveChangesAsync(); var lifecycle = new UpliftLifecycleService( new UpliftDataService(context), new DispatchDataService(context), Mock.Of(), new FakeEmailSender(deliver: true), Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), Microsoft.Extensions.Options.Options.Create(NewOptions()), TimeProvider.System, Mock.Of>()); var expired = await lifecycle.ExpireDueAsync(CancellationToken.None); expired.Should().Be(1); var req = context.DispatchUpliftRequests.Single(); req.Status.Should().Be(UpliftStatus.Expired); req.DecidedAt.Should().NotBeNull(); context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_expired" && a.OldValue == UpliftStatus.Pending && a.NewValue == UpliftStatus.Expired); } [Fact] public async Task ExpireDue_DoesNotTransitionTerminalRequests() { using var context = NewContext(); var (_, _, dispatch) = await SeedAsync(context, status: "In Progress", nte: 1000m); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { DispatchId = dispatch.Id, CurrentNTE = 1000m, RequestedNTE = 1500m, VendorReason = "reason", Status = UpliftStatus.Approved, RequiredTier = 1, ExpiresAt = DateTime.UtcNow.AddHours(-1), CreatedDate = DateTime.UtcNow.AddHours(-2) }); await context.SaveChangesAsync(); var lifecycle = new UpliftLifecycleService( new UpliftDataService(context), new DispatchDataService(context), Mock.Of(), new FakeEmailSender(deliver: true), Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), Microsoft.Extensions.Options.Options.Create(NewOptions()), TimeProvider.System, Mock.Of>()); var expired = await lifecycle.ExpireDueAsync(CancellationToken.None); expired.Should().Be(0); context.DispatchUpliftRequests.Single().Status.Should().Be(UpliftStatus.Approved); } // --- SH-101: orphan dispatch (deleted/unresolvable) must be skipped, not mutated/audited/saved --- [Fact] public async Task ExpireDue_OrphanDispatchIsSkipped_ValidRequestStillExpiresAndAuditsOnce() { var orphanReq = new DispatchUpliftRequest { Id = 101, DispatchId = 404, Status = UpliftStatus.Pending, RequiredTier = 1 }; var validReq = new DispatchUpliftRequest { Id = 202, DispatchId = 7, Status = UpliftStatus.Pending, RequiredTier = 1 }; var upliftData = new Mock(); upliftData.Setup(u => u.GetDueForExpiryAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { orphanReq, validReq }); upliftData.Setup(u => u.GetWorkOrderIdForUpliftAsync(202, It.IsAny())) .ReturnsAsync(99); upliftData.Setup(u => u.SaveChangesAsync(It.IsAny())) .Returns(Task.CompletedTask); var dispatch = new Dispatch { Id = 7, DispatchNumber = "DIS-7", WorkOrderId = 99 }; var dispatchData = new Mock(); dispatchData.Setup(d => d.GetByIdAsync(404)).ReturnsAsync((Dispatch?)null); dispatchData.Setup(d => d.GetByIdAsync(7)).ReturnsAsync(dispatch); dispatchData.Setup(d => d.StageAuditLogAsync(It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var lifecycle = new UpliftLifecycleService( upliftData.Object, dispatchData.Object, Mock.Of(), new FakeEmailSender(deliver: true), Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), Microsoft.Extensions.Options.Options.Create(NewOptions()), TimeProvider.System, Mock.Of>()); var expired = await lifecycle.ExpireDueAsync(CancellationToken.None); expired.Should().Be(1); orphanReq.Status.Should().Be(UpliftStatus.Pending, "orphan must not be mutated"); orphanReq.DecidedAt.Should().BeNull("orphan must not be mutated"); validReq.Status.Should().Be(UpliftStatus.Expired, "valid request must expire"); validReq.DecidedAt.Should().NotBeNull("valid request must be decided"); dispatchData.Verify(d => d.StageAuditLogAsync(It.IsAny(), It.IsAny()), Times.Once, "only the valid request stages an audit"); upliftData.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once, "only the valid request saves"); } // --- Escalation is once-per-request (dedup) --- [Fact] public async Task EscalateDue_IsOncePerRequest_SecondRunIsNoop() { using var context = NewContext(); var (_, _, dispatch) = await SeedAsync(context, status: "In Progress", nte: 1000m); var sentAt = DateTime.UtcNow.AddHours(-3); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { DispatchId = dispatch.Id, CurrentNTE = 1000m, RequestedNTE = 1500m, VendorReason = "reason", Status = UpliftStatus.Pending, RequiredTier = 1, InitialNotificationSentAt = sentAt, CreatedDate = DateTime.UtcNow.AddHours(-4) }); await context.SaveChangesAsync(); var lifecycle = new UpliftLifecycleService( new UpliftDataService(context), new DispatchDataService(context), Mock.Of(), new FakeEmailSender(deliver: true), Microsoft.Extensions.Options.Options.Create(new FrontendOptions { FrontendBaseUrl = "https://shoc.test" }), Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions { EscalationAfterHours = 1, EscalationRecipients = new[] { "escalate@example.com" } }), TimeProvider.System, Mock.Of>()); var first = await lifecycle.EscalateDueAsync(CancellationToken.None); var second = await lifecycle.EscalateDueAsync(CancellationToken.None); first.Should().Be(1); second.Should().Be(0); context.DispatchUpliftRequests.Single().EscalatedAt.Should().NotBeNull(); context.WorkOrderAuditLogs.Should().ContainSingle(a => a.Action == "uplift_escalated"); } // --- Internal evidence download security --- [Fact] public async Task GetEvidenceForDownload_ReturnsOk_WhenPassedUpliftEvidence() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); var storage = new FakeDocumentStorage(); await storage.SaveAsync(vendor.Id, dispatch.Id, "evidence.bin", new MemoryStream("%PDF"u8.ToArray()), CancellationToken.None); context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { DispatchId = dispatch.Id, CurrentNTE = 1000m, RequestedNTE = 1500m, VendorReason = "reason", Status = UpliftStatus.Pending, RequiredTier = 1, EvidenceDocumentId = 1, CreatedDate = DateTime.UtcNow }); await context.SaveChangesAsync(); var service = NewUpliftService(context, storage); var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None); result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok); result.ContentType.Should().Be("application/pdf"); result.FileName.Should().Be("invoice.pdf"); } [Fact] public async Task GetEvidenceForDownload_ThrowsForbidden_WhenUserLacksRequiredTier() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { DispatchId = dispatch.Id, CurrentNTE = 1000m, RequestedNTE = 4000m, VendorReason = "reason", Status = UpliftStatus.Pending, RequiredTier = 2, EvidenceDocumentId = 1, CreatedDate = DateTime.UtcNow }); await context.SaveChangesAsync(); var service = NewUpliftService(context, new FakeDocumentStorage()); var act = () => service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None); await act.Should().ThrowAsync(); } [Fact] public async Task GetEvidenceForDownload_ReturnsNotFound_WhenNoLinkedEvidence() { using var context = NewContext(); var (_, _, dispatch) = await SeedAsync(context, nte: 1000m); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { DispatchId = dispatch.Id, CurrentNTE = 1000m, RequestedNTE = 1500m, VendorReason = "reason", Status = UpliftStatus.Pending, RequiredTier = 1, EvidenceDocumentId = null, CreatedDate = DateTime.UtcNow }); await context.SaveChangesAsync(); var service = NewUpliftService(context, new FakeDocumentStorage()); var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None); result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound); } [Fact] public async Task GetEvidenceForDownload_ReturnsLocked_WhenScanNotPassed() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id, scanStatus: "Pending")); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { DispatchId = dispatch.Id, CurrentNTE = 1000m, RequestedNTE = 1500m, VendorReason = "reason", Status = UpliftStatus.Pending, RequiredTier = 1, EvidenceDocumentId = 1, CreatedDate = DateTime.UtcNow }); await context.SaveChangesAsync(); var service = NewUpliftService(context, new FakeDocumentStorage()); var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None); result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Locked); } [Fact] public async Task GetEvidenceForDownload_ReturnsNotFound_WhenLinkedDocumentIsCompletionPurpose() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id, purpose: "Completion")); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { DispatchId = dispatch.Id, CurrentNTE = 1000m, RequestedNTE = 1500m, VendorReason = "reason", Status = UpliftStatus.Pending, RequiredTier = 1, EvidenceDocumentId = 1, CreatedDate = DateTime.UtcNow }); await context.SaveChangesAsync(); var service = NewUpliftService(context, new FakeDocumentStorage()); var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None); result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound); } [Fact] public async Task GetEvidenceForDownload_ReturnsNotFound_WhenDispatchLinkageBroken() { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); var otherWorkOrder = new WorkOrder { WorkerOrderTitle = "Other" }; context.Add(otherWorkOrder); await context.SaveChangesAsync(); var otherDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = otherWorkOrder.Id, Status = "Completed" }; context.Dispatches.Add(otherDispatch); // Evidence document belongs to a different dispatch than the uplift request. context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, otherDispatch.Id, otherWorkOrder.Id)); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { DispatchId = dispatch.Id, CurrentNTE = 1000m, RequestedNTE = 1500m, VendorReason = "reason", Status = UpliftStatus.Pending, RequiredTier = 1, EvidenceDocumentId = 1, CreatedDate = DateTime.UtcNow }); await context.SaveChangesAsync(); var service = NewUpliftService(context, new FakeDocumentStorage()); var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None); result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound); } // --- A cancelled work order takes no new uplift request --- [Theory] [InlineData(true, "Canceled")] [InlineData(false, "Cancelled")] public async Task RequestUplift_OnCancelledWorkOrder_IsRefusedAndCreatesNothing(bool lifecycleCanceled, string statusText) { using var context = NewContext(); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); workOrder.Status = statusText; if (lifecycleCanceled) workOrder.LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled; await context.SaveChangesAsync(); var service = NewPortalService(context, new FakeEmailSender(deliver: true)); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); var act = () => service.RequestUpliftAsync(session!, dispatch.Id, 3500m, "reason", null, 1, CancellationToken.None); await act.Should().ThrowAsync().WithMessage("*cancelled work order*"); context.DispatchUpliftRequests.Should().BeEmpty(); } [Fact] public async Task RequestUplift_WaitsForAWorkOrderCancelInFlightAndIsThenRefused() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; using var context = new ApplicationDbContext(options); var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m); context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); await context.SaveChangesAsync(); var service = NewPortalService(context, new FakeEmailSender(deliver: true)); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); // A work order cancel holding the work order's lock, committing only after the // vendor's request has started. var cancelEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var releaseCancel = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var cancelContext = new ApplicationDbContext(options); var cancel = new UpliftDataService(cancelContext).ExecuteWorkOrderMutationAsync(workOrder.Id, async ct => { cancelEntered.SetResult(); await releaseCancel.Task; var tracked = await cancelContext.workOrders.SingleAsync(w => w.Id == workOrder.Id, ct); tracked.LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled; await cancelContext.SaveChangesAsync(ct); return true; }, CancellationToken.None); await cancelEntered.Task; var request = service.RequestUpliftAsync(session!, dispatch.Id, 3500m, "reason", null, 1, CancellationToken.None); var finishedFirst = await Task.WhenAny(request, Task.Delay(TimeSpan.FromSeconds(2))); finishedFirst.Should().NotBeSameAs(request, "the request must wait for the cancel holding the work order lock"); releaseCancel.SetResult(); await cancel; await FluentActions.Awaiting(() => request).Should().ThrowAsync() .WithMessage("*cancelled work order*"); using var verify = new ApplicationDbContext(options); verify.DispatchUpliftRequests.Should().BeEmpty(); } }