2026-08-11 08:58:19 -03:00
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 = 2500 m ,
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 = 1000 m )
{
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 ,
2026-09-23 01:24:43 -03:00
IVendorDocumentStoragePort ? storage = null ,
IUpliftDataService ? upliftData = null )
2026-08-11 08:58:19 -03:00
{
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 ) ,
2026-09-23 01:24:43 -03:00
upliftData ? ? new UpliftDataService ( context ) ,
2026-08-11 08:58:19 -03:00
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 : 1000 m ) ;
var service = NewPortalService ( context , new FakeEmailSender ( deliver : true ) ) ;
var session = await service . ResolveSessionAsync ( Token , CancellationToken . None ) ;
var result = await service . RequestUpliftAsync ( session ! , dispatch . Id , 1000 m , "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 ( 1000 m ) ;
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 : 1000 m ) ;
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 m , "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 : 1000 m ) ;
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.01 m , "reason" , null , 1 , CancellationToken . None ) ;
result . RequiredTier . Should ( ) . Be ( 2 ) ;
}
2026-09-23 01:24:43 -03:00
[Fact]
public async Task RequestUplift_ConcurrentActiveRequest_MapsPersistenceConflict ( )
{
using var context = NewContext ( ) ;
var ( vendor , workOrder , dispatch ) = await SeedAsync ( context , nte : 1000 m ) ;
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 ,
1500 m ,
"reason" ,
null ,
1 ,
CancellationToken . None ) ;
await act . Should ( ) . ThrowAsync < SeaHaven . Services . Exceptions . UpliftConflictException > ( ) ;
}
2026-08-11 08:58:19 -03:00
// --- 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 : 1000 m ) ;
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 , 1500 m , "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 : 1000 m ) ;
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 , 1500 m , "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 : 1000 m ) ;
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 , 1500 m , "need parts" , "key-1" , 1 , CancellationToken . None ) ;
var replay = await service . RequestUpliftAsync ( session ! , dispatch . Id , 1500 m , "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 : 1000 m ) ;
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 , 1500 m , "need parts" , "key-1" , firstEvidenceId , CancellationToken . None ) ;
var act = ( ) = > service . RequestUpliftAsync ( session ! , dispatch . Id , 1500 m , "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 : 1000 m ) ;
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 , 1500 m , "need parts" , "key-1" , 1 , CancellationToken . None ) ;
var act = ( ) = > service . RequestUpliftAsync ( session ! , dispatch . Id , 1600 m , "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 : 1000 m ) ;
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 , 1500 m , "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 : 1000 m ) ;
var service = NewPortalService ( context , new FakeEmailSender ( deliver : true ) ) ;
var session = await service . ResolveSessionAsync ( Token , CancellationToken . None ) ;
var act = ( ) = > service . RequestUpliftAsync ( session ! , dispatch . Id , 1500 m , "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 : 1000 m ) ;
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 , 1500 m , "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 , 1700 m , "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 ( 1700 m ) ;
context . WorkOrderAuditLogs . Should ( ) . Contain ( a = > a . Action = = "uplift_revised" ) ;
}
2026-09-25 02:35:06 -03:00
[Fact]
public async Task Revise_WorkOrderRequest_IsRefusedAsNotFound_AndLeavesTheRowUnchanged ( )
{
using var context = NewContext ( ) ;
var ( vendor , workOrder , dispatch ) = await SeedAsync ( context , nte : 600 m ) ;
context . VendorCompletionDocuments . Add ( EvidenceDocument ( vendor . Id , dispatch . Id , workOrder . Id ) ) ;
// Raised from the work order by an internal user and sent back for changes:
// RequestedNTE holds the 90 increase, not a total.
var workOrderRequest = new DispatchUpliftRequest
{
DispatchId = dispatch . Id ,
CurrentNTE = 600 m ,
RequestedNTE = 90 m ,
Status = UpliftStatus . ChangesRequested ,
RequiredTier = 1 ,
VendorReason = "Extra parts" ,
RequestedByVendorName = "Alex Dispatcher" ,
NotificationStatus = "Sent" ,
createdby = "dispatcher-1" ,
CreatedDate = new DateTime ( 2026 , 3 , 1 ) ,
} ;
context . DispatchUpliftRequests . Add ( workOrderRequest ) ;
await context . SaveChangesAsync ( ) ;
var service = NewPortalService ( context , new FakeEmailSender ( deliver : true ) ) ;
var session = await service . ResolveSessionAsync ( Token , CancellationToken . None ) ;
var act = ( ) = > service . ReviseUpliftAsync ( session ! , dispatch . Id , workOrderRequest . Id , 900 m , "vendor total" , 1 , CancellationToken . None ) ;
await act . Should ( ) . ThrowAsync < KeyNotFoundException > ( ) ;
var after = context . DispatchUpliftRequests . AsNoTracking ( ) . Single ( u = > u . Id = = workOrderRequest . Id ) ;
after . Status . Should ( ) . Be ( UpliftStatus . ChangesRequested ) ;
after . RequestedNTE . Should ( ) . Be ( 90 m ) ;
after . CurrentNTE . Should ( ) . Be ( 600 m ) ;
after . VendorReason . Should ( ) . Be ( "Extra parts" ) ;
after . createdby . Should ( ) . Be ( "dispatcher-1" ) ;
context . WorkOrderAuditLogs . Should ( ) . NotContain ( a = > a . Action = = "uplift_revised" ) ;
}
2026-08-11 08:58:19 -03:00
[Fact]
public async Task Withdraw_Pending_TransitionsToWithdrawn_SetsDecidedAt ( )
{
using var context = NewContext ( ) ;
var ( vendor , workOrder , dispatch ) = await SeedAsync ( context , nte : 1000 m ) ;
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 , 1500 m , "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 : 1000 m ) ;
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 , 1500 m , "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 > ( ) ;
}
2026-09-25 02:51:48 -03:00
// Raised from the work order by an internal user: createdby is set and RequestedNTE
// holds the increase. Vendor sessions have no identity user, so they never set it.
private static DispatchUpliftRequest WorkOrderRequest ( int dispatchId , string status ) = > new ( )
{
DispatchId = dispatchId ,
CurrentNTE = 600 m ,
RequestedNTE = 90 m ,
Status = status ,
RequiredTier = 1 ,
VendorReason = "Extra parts" ,
RequestedByVendorName = "Alex Dispatcher" ,
NotificationStatus = "Sent" ,
createdby = "dispatcher-1" ,
CreatedDate = new DateTime ( 2026 , 3 , 1 ) ,
} ;
[Theory]
[InlineData("Pending", "withdraw")]
[InlineData("ChangesRequested", "withdraw")]
[InlineData("Pending", "cancel")]
[InlineData("ChangesRequested", "cancel")]
public async Task Withdraw_WorkOrderRequest_IsRefusedAsNotFound_AndLeavesTheRowUnchanged ( string status , string route )
{
using var context = NewContext ( ) ;
var ( _ , _ , dispatch ) = await SeedAsync ( context , nte : 600 m ) ;
var workOrderRequest = WorkOrderRequest ( dispatch . Id , status ) ;
context . DispatchUpliftRequests . Add ( workOrderRequest ) ;
await context . SaveChangesAsync ( ) ;
var service = NewPortalService ( context , new FakeEmailSender ( deliver : true ) ) ;
var session = await service . ResolveSessionAsync ( Token , CancellationToken . None ) ;
Func < Task > act = route = = "cancel"
? ( ) = > service . CancelUpliftRequestAsync ( session ! , dispatch . Id , workOrderRequest . Id , CancellationToken . None )
: ( ) = > service . WithdrawUpliftAsync ( session ! , dispatch . Id , workOrderRequest . Id , CancellationToken . None ) ;
await act . Should ( ) . ThrowAsync < KeyNotFoundException > ( ) ;
var after = context . DispatchUpliftRequests . AsNoTracking ( ) . Single ( u = > u . Id = = workOrderRequest . Id ) ;
after . Status . Should ( ) . Be ( status ) ;
after . DecidedAt . Should ( ) . BeNull ( ) ;
after . RequestedNTE . Should ( ) . Be ( 90 m ) ;
after . createdby . Should ( ) . Be ( "dispatcher-1" ) ;
context . WorkOrderAuditLogs . Should ( ) . NotContain ( a = > a . Action = = "uplift_withdraw" | | a . Action = = "uplift_cancel" ) ;
}
[Fact]
public async Task Cancel_VendorRaisedChangesRequested_StillWithdraws ( )
{
using var context = NewContext ( ) ;
var ( vendor , workOrder , dispatch ) = await SeedAsync ( context , nte : 1000 m ) ;
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 , 1500 m , "reason" , null , 1 , CancellationToken . None ) ;
context . DispatchUpliftRequests . Single ( ) . Status = UpliftStatus . ChangesRequested ;
await context . SaveChangesAsync ( ) ;
var result = await service . CancelUpliftRequestAsync ( session ! , dispatch . Id , created . Id , CancellationToken . None ) ;
result . Status . Should ( ) . Be ( UpliftStatus . Withdrawn ) ;
context . DispatchUpliftRequests . AsNoTracking ( ) . Single ( ) . Status . Should ( ) . Be ( UpliftStatus . Withdrawn ) ;
context . WorkOrderAuditLogs . Should ( ) . Contain ( a = > a . Action = = "uplift_cancel" & & a . OldValue = = UpliftStatus . ChangesRequested ) ;
}
[Fact]
public async Task DispatchDetail_ReportsRaisedByVendor_PerCreationPath ( )
{
using var context = NewContext ( ) ;
var ( vendor , workOrder , dispatch ) = await SeedAsync ( context , nte : 1000 m ) ;
context . VendorCompletionDocuments . Add ( EvidenceDocument ( vendor . Id , dispatch . Id , workOrder . Id ) ) ;
await context . SaveChangesAsync ( ) ;
var service = NewPortalService ( context , new FakeEmailSender ( deliver : true ) ) ;
var session = await service . ResolveSessionAsync ( Token , CancellationToken . None ) ;
var vendorRaised = await service . RequestUpliftAsync ( session ! , dispatch . Id , 1500 m , "reason" , null , 1 , CancellationToken . None ) ;
await service . WithdrawUpliftAsync ( session ! , dispatch . Id , vendorRaised . Id , CancellationToken . None ) ;
var workOrderRequest = WorkOrderRequest ( dispatch . Id , UpliftStatus . Pending ) ;
context . DispatchUpliftRequests . Add ( workOrderRequest ) ;
await context . SaveChangesAsync ( ) ;
var detail = await service . GetDispatchDetailAsync ( session ! , dispatch . Id , CancellationToken . None ) ;
detail ! . UpliftRequests . Should ( ) . HaveCount ( 2 ) ;
detail . UpliftRequests . Single ( u = > u . Id = = vendorRaised . Id ) . RaisedByVendor . Should ( ) . BeTrue ( ) ;
detail . UpliftRequests . Single ( u = > u . Id = = workOrderRequest . Id ) . RaisedByVendor . Should ( ) . BeFalse ( ) ;
}
2026-08-11 08:58:19 -03:00
[Fact]
public async Task RequestChanges_Pending_TransitionsToChangesRequested_AuditsOldValue ( )
{
using var context = NewContext ( ) ;
var ( vendor , workOrder , dispatch ) = await SeedAsync ( context , nte : 1000 m ) ;
context . DispatchUpliftRequests . Add ( new DispatchUpliftRequest
{
DispatchId = dispatch . Id ,
CurrentNTE = 1000 m ,
RequestedNTE = 1500 m ,
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 : 1000 m ) ;
context . DispatchUpliftRequests . Add ( new DispatchUpliftRequest
{
DispatchId = dispatch . Id ,
CurrentNTE = 1000 m ,
RequestedNTE = 1500 m ,
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 : 1000 m ) ;
context . DispatchUpliftRequests . Add ( new DispatchUpliftRequest
{
DispatchId = dispatch . Id ,
CurrentNTE = 1000 m ,
RequestedNTE = 1500 m ,
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 : 1000 m ) ;
context . DispatchUpliftRequests . Add ( new DispatchUpliftRequest
{
DispatchId = dispatch . Id ,
CurrentNTE = 1000 m ,
RequestedNTE = 1800 m ,
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 ( 1800 m ) ;
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 : 1000 m ) ;
context . DispatchUpliftRequests . Add ( new DispatchUpliftRequest
{
DispatchId = dispatch . Id ,
CurrentNTE = 1000 m ,
RequestedNTE = 1800 m ,
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 ( 1000 m ) ;
}
// --- 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 : 1000 m ) ;
context . DispatchUpliftRequests . Add ( new DispatchUpliftRequest
{
DispatchId = dispatch . Id ,
CurrentNTE = 1000 m ,
RequestedNTE = 1500 m ,
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 : 1000 m ) ;
context . DispatchUpliftRequests . Add ( new DispatchUpliftRequest
{
DispatchId = dispatch . Id ,
CurrentNTE = 1000 m ,
RequestedNTE = 1500 m ,
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 } ) ;
2026-09-24 23:30:16 -03:00
upliftData . Setup ( u = > u . GetWorkOrderIdForUpliftAsync ( 202 , It . IsAny < CancellationToken > ( ) ) )
. ReturnsAsync ( 99 ) ;
2026-08-11 08:58:19 -03:00
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 : 1000 m ) ;
var sentAt = DateTime . UtcNow . AddHours ( - 3 ) ;
context . DispatchUpliftRequests . Add ( new DispatchUpliftRequest
{
DispatchId = dispatch . Id ,
CurrentNTE = 1000 m ,
RequestedNTE = 1500 m ,
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 : 1000 m ) ;
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 = 1000 m ,
RequestedNTE = 1500 m ,
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 : 1000 m ) ;
context . VendorCompletionDocuments . Add ( EvidenceDocument ( vendor . Id , dispatch . Id , workOrder . Id ) ) ;
context . DispatchUpliftRequests . Add ( new DispatchUpliftRequest
{
DispatchId = dispatch . Id ,
CurrentNTE = 1000 m ,
RequestedNTE = 4000 m ,
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 : 1000 m ) ;
context . DispatchUpliftRequests . Add ( new DispatchUpliftRequest
{
DispatchId = dispatch . Id ,
CurrentNTE = 1000 m ,
RequestedNTE = 1500 m ,
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 : 1000 m ) ;
context . VendorCompletionDocuments . Add ( EvidenceDocument ( vendor . Id , dispatch . Id , workOrder . Id , scanStatus : "Pending" ) ) ;
context . DispatchUpliftRequests . Add ( new DispatchUpliftRequest
{
DispatchId = dispatch . Id ,
CurrentNTE = 1000 m ,
RequestedNTE = 1500 m ,
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 : 1000 m ) ;
context . VendorCompletionDocuments . Add ( EvidenceDocument ( vendor . Id , dispatch . Id , workOrder . Id , purpose : "Completion" ) ) ;
context . DispatchUpliftRequests . Add ( new DispatchUpliftRequest
{
DispatchId = dispatch . Id ,
CurrentNTE = 1000 m ,
RequestedNTE = 1500 m ,
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 : 1000 m ) ;
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 = 1000 m ,
RequestedNTE = 1500 m ,
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 ) ;
}
}