mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-04 13:42:14 +00:00
Approve, reject, request-changes, expiry and escalation staged their work order audit with WorkOrderId = dispatch.WorkOrderId ?? 0. WorkOrderAuditLogs requires a real work order, so any uplift on a dispatch without an owning work order failed to save: the decision returned a 500 and the request stayed Pending, and the expiry sweep failed on it every run. The audit now goes to the work order the uplift resolves to through the existing owner-or-linked read that revoke already uses. When none resolves, the status change is saved on the request and no audit row is written.
920 lines
40 KiB
C#
920 lines
40 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,
|
|
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<ICommentDataService>();
|
|
commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<PortalCommentData>());
|
|
return new VendorPortalService(
|
|
tokenService,
|
|
new DispatchDataService(context),
|
|
upliftData ?? 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);
|
|
}
|
|
|
|
[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]
|
|
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.GetWorkOrderIdForUpliftAsync(202, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(99);
|
|
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);
|
|
}
|
|
}
|