shoc-backend/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs
2026-08-11 08:58:19 -03:00

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