mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
887 lines
39 KiB
C#
887 lines
39 KiB
C#
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<ApplicationDbContext>()
|
|
.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<Claim> { 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<string, byte[]> _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<bool> 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)
|
|
{
|
|
var vendorData = new VendorDataService(context);
|
|
var tokenService = new VendorPortalTokenService(vendorData, Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions()));
|
|
storage ??= new FakeDocumentStorage();
|
|
var commentData = new Mock<ICommentDataService>();
|
|
commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<PortalCommentData>());
|
|
return new VendorPortalService(
|
|
tokenService,
|
|
new DispatchDataService(context),
|
|
new UpliftDataService(context),
|
|
commentData.Object,
|
|
Mock.Of<IUserDataService>(),
|
|
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);
|
|
}
|
|
|
|
// --- 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<InvalidOperationException>();
|
|
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<InvalidOperationException>();
|
|
}
|
|
|
|
// --- 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<InvalidOperationException>();
|
|
}
|
|
|
|
[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<InvalidOperationException>();
|
|
}
|
|
|
|
// --- 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<InvalidOperationException>();
|
|
}
|
|
|
|
// --- 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 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<InvalidOperationException>();
|
|
}
|
|
|
|
[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<InvalidOperationException>();
|
|
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<IUserDataService>(),
|
|
new FakeEmailSender(deliver: true),
|
|
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
|
|
Microsoft.Extensions.Options.Options.Create(NewOptions()),
|
|
TimeProvider.System,
|
|
Mock.Of<ILogger<UpliftLifecycleService>>());
|
|
|
|
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<IUserDataService>(),
|
|
new FakeEmailSender(deliver: true),
|
|
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
|
|
Microsoft.Extensions.Options.Options.Create(NewOptions()),
|
|
TimeProvider.System,
|
|
Mock.Of<ILogger<UpliftLifecycleService>>());
|
|
|
|
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<IUpliftDataService>();
|
|
upliftData.Setup(u => u.GetDueForExpiryAsync(It.IsAny<DateTime>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<DispatchUpliftRequest> { orphanReq, validReq });
|
|
upliftData.Setup(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var dispatch = new Dispatch { Id = 7, DispatchNumber = "DIS-7", WorkOrderId = 99 };
|
|
var dispatchData = new Mock<IDispatchDataService>();
|
|
dispatchData.Setup(d => d.GetByIdAsync(404)).ReturnsAsync((Dispatch?)null);
|
|
dispatchData.Setup(d => d.GetByIdAsync(7)).ReturnsAsync(dispatch);
|
|
dispatchData.Setup(d => d.StageAuditLogAsync(It.IsAny<WorkOrderAuditLog>(), It.IsAny<CancellationToken>()))
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var lifecycle = new UpliftLifecycleService(
|
|
upliftData.Object,
|
|
dispatchData.Object,
|
|
Mock.Of<IUserDataService>(),
|
|
new FakeEmailSender(deliver: true),
|
|
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
|
|
Microsoft.Extensions.Options.Options.Create(NewOptions()),
|
|
TimeProvider.System,
|
|
Mock.Of<ILogger<UpliftLifecycleService>>());
|
|
|
|
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<WorkOrderAuditLog>(), It.IsAny<CancellationToken>()), Times.Once, "only the valid request stages an audit");
|
|
upliftData.Verify(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()), 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<IUserDataService>(),
|
|
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<ILogger<UpliftLifecycleService>>());
|
|
|
|
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<UpliftForbiddenException>();
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|