mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-03 16:43:28 +00:00
Behavior coverage for the new queue read fields so the contract cannot regress silently: workOrderClosed follows terminal lifecycle statuses, decidedByName resolves from the deciding user, attachmentCount counts active dispatch documents, and pendingExposureTotal sums granted amounts across all pending requests independent of paging.
616 lines
27 KiB
C#
616 lines
27 KiB
C#
using Data.SeaHavenIndustries;
|
|
using Data.SeaHavenIndustries.Enums;
|
|
using FluentAssertions;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
using Moq;
|
|
using SeaHaven.DataServices.Implementation;
|
|
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;
|
|
|
|
// Behavior tests for the approval queue read contract. These exercise the real
|
|
// service + data-service layers against an in-memory DbContext so that queue
|
|
// ordering, filters, flattened work-order fields, exposure aggregation, and
|
|
// per-tier read authorization are validated through the public contract.
|
|
public sealed class UpliftQueueReadTests
|
|
{
|
|
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 UpliftService NewService(ApplicationDbContext context) =>
|
|
new(new UpliftDataService(context),
|
|
new DispatchDataService(context),
|
|
Mock.Of<IVendorDocumentStoragePort>(),
|
|
TimeProvider.System,
|
|
Microsoft.Extensions.Options.Options.Create(NewOptions()));
|
|
|
|
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 static DispatchUpliftRequest Request(
|
|
Dispatch dispatch,
|
|
string status,
|
|
DateTime created,
|
|
DateTime? decided = null,
|
|
int tier = 1,
|
|
decimal requested = 100m,
|
|
string? reason = null,
|
|
string? requestedBy = null,
|
|
string? createdBy = null,
|
|
decimal? currentNte = null) => new()
|
|
{
|
|
DispatchId = dispatch.Id,
|
|
Status = status,
|
|
CreatedDate = created,
|
|
DecidedAt = decided,
|
|
RequiredTier = tier,
|
|
RequestedNTE = requested,
|
|
CurrentNTE = currentNte,
|
|
VendorReason = reason,
|
|
RequestedByVendorName = requestedBy,
|
|
createdby = createdBy,
|
|
NotificationStatus = "Pending"
|
|
};
|
|
|
|
private static async Task<(Vendor Vendor, WorkOrder WorkOrder)> SeedWorkOrderAsync(
|
|
ApplicationDbContext context,
|
|
string number,
|
|
string site,
|
|
string service,
|
|
string vendorName = "Gateway")
|
|
{
|
|
var vendor = new Vendor { CompanyName = vendorName, IsActive = true };
|
|
var workOrder = new WorkOrder
|
|
{
|
|
InternalWONumber = number,
|
|
WorkerOrderNumber = $"legacy-{number}",
|
|
SiteCode = site,
|
|
Service = service,
|
|
WorkerOrderTitle = "Repair"
|
|
};
|
|
context.AddRange(vendor, workOrder);
|
|
await context.SaveChangesAsync();
|
|
return (vendor, workOrder);
|
|
}
|
|
|
|
private static async Task<Dispatch> SeedDispatchAsync(
|
|
ApplicationDbContext context, Vendor vendor, WorkOrder workOrder, string number)
|
|
{
|
|
var dispatch = new Dispatch
|
|
{
|
|
VendorId = vendor.Id,
|
|
WorkOrderId = workOrder.Id,
|
|
DispatchNumber = number,
|
|
Status = "Completed",
|
|
NTEAmount = 1000m
|
|
};
|
|
context.Dispatches.Add(dispatch);
|
|
await context.SaveChangesAsync();
|
|
return dispatch;
|
|
}
|
|
|
|
// --- Ordering ---
|
|
|
|
[Fact]
|
|
public async Task List_PendingStatus_OrdersOldestRequestFirst()
|
|
{
|
|
using var context = NewContext();
|
|
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
|
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
|
context.AddRange(vendor, workOrder);
|
|
await context.SaveChangesAsync();
|
|
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
|
context.DispatchUpliftRequests.AddRange(
|
|
Request(dispatch, "Pending", new DateTime(2026, 3, 3)),
|
|
Request(dispatch, "Pending", new DateTime(2026, 3, 1)),
|
|
Request(dispatch, "Pending", new DateTime(2026, 3, 2)));
|
|
await context.SaveChangesAsync();
|
|
var service = NewService(context);
|
|
|
|
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
|
|
|
result.Items.Select(i => i.RequestedAt).Should().Equal(
|
|
new DateTime(2026, 3, 1),
|
|
new DateTime(2026, 3, 2),
|
|
new DateTime(2026, 3, 3));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task List_ApprovedStatus_OrdersMostRecentlyDecidedFirst()
|
|
{
|
|
using var context = NewContext();
|
|
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
|
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
|
context.AddRange(vendor, workOrder);
|
|
await context.SaveChangesAsync();
|
|
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
|
context.DispatchUpliftRequests.AddRange(
|
|
Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 10)),
|
|
Request(dispatch, "Approved", new DateTime(2026, 3, 2), decided: new DateTime(2026, 3, 20)),
|
|
Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 15)));
|
|
await context.SaveChangesAsync();
|
|
var service = NewService(context);
|
|
|
|
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
|
|
|
|
result.Items.Select(i => i.DecidedAt).Should().Equal(
|
|
new DateTime(2026, 3, 20),
|
|
new DateTime(2026, 3, 15),
|
|
new DateTime(2026, 3, 10));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task List_WithoutStatusFilter_KeepsHistoricalNewestRequestFirstOrder()
|
|
{
|
|
using var context = NewContext();
|
|
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
|
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
|
context.AddRange(vendor, workOrder);
|
|
await context.SaveChangesAsync();
|
|
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
|
context.DispatchUpliftRequests.AddRange(
|
|
Request(dispatch, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)),
|
|
Request(dispatch, "Approved", new DateTime(2026, 3, 10), decided: new DateTime(2026, 3, 11)));
|
|
await context.SaveChangesAsync();
|
|
var service = NewService(context);
|
|
|
|
var result = await service.ListAsync(UserWithRoles("Approver"), null, null, 1, 25, CancellationToken.None);
|
|
|
|
result.Items.Select(i => i.RequestedAt).Should().ContainInOrder(
|
|
new DateTime(2026, 3, 10),
|
|
new DateTime(2026, 3, 1));
|
|
}
|
|
|
|
// --- Filters and scoping ---
|
|
|
|
[Fact]
|
|
public async Task List_StatusAndTierFilters_Combine()
|
|
{
|
|
using var context = NewContext();
|
|
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
|
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
|
context.AddRange(vendor, workOrder);
|
|
await context.SaveChangesAsync();
|
|
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
|
context.DispatchUpliftRequests.AddRange(
|
|
Request(dispatch, "Pending", new DateTime(2026, 3, 1), tier: 1),
|
|
Request(dispatch, "Pending", new DateTime(2026, 3, 2), tier: 2),
|
|
Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), tier: 2));
|
|
await context.SaveChangesAsync();
|
|
var service = NewService(context);
|
|
|
|
var result = await service.ListAsync(UserWithRoles("Manager"), "Pending", 2, 1, 25, CancellationToken.None);
|
|
|
|
result.Total.Should().Be(1);
|
|
result.Items.Should().ContainSingle(i => i.RequiredTier == 2 && i.Status == "Pending");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task List_ExcludesDeletedRequestsAndRequestsOnDeletedDispatches()
|
|
{
|
|
using var context = NewContext();
|
|
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
|
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
|
context.AddRange(vendor, workOrder);
|
|
await context.SaveChangesAsync();
|
|
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
|
var deletedDispatch = new Dispatch
|
|
{
|
|
VendorId = vendor.Id,
|
|
WorkOrderId = workOrder.Id,
|
|
DispatchNumber = "DIS-DELETED",
|
|
Status = "Completed",
|
|
IsDeleted = true
|
|
};
|
|
context.Dispatches.Add(deletedDispatch);
|
|
await context.SaveChangesAsync();
|
|
context.DispatchUpliftRequests.AddRange(
|
|
Request(dispatch, "Pending", new DateTime(2026, 3, 1)),
|
|
new DispatchUpliftRequest
|
|
{
|
|
DispatchId = dispatch.Id,
|
|
Status = "Pending",
|
|
CreatedDate = new DateTime(2026, 3, 2),
|
|
IsDeleted = true,
|
|
RequiredTier = 1,
|
|
RequestedNTE = 100m
|
|
},
|
|
Request(deletedDispatch, "Pending", new DateTime(2026, 3, 3)));
|
|
await context.SaveChangesAsync();
|
|
var service = NewService(context);
|
|
|
|
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
|
|
|
result.Total.Should().Be(1);
|
|
result.Items.Should().ContainSingle(i => i.RequestedAt == new DateTime(2026, 3, 1));
|
|
}
|
|
|
|
// --- Flattened queue fields ---
|
|
|
|
[Fact]
|
|
public async Task List_MapsFlattenedWorkOrderAndRequesterFields()
|
|
{
|
|
using var context = NewContext();
|
|
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC");
|
|
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
|
context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" });
|
|
await context.SaveChangesAsync();
|
|
context.DispatchUpliftRequests.Add(Request(
|
|
dispatch, "Pending", new DateTime(2026, 3, 2),
|
|
tier: 2, requested: 400m, reason: "Scope grew",
|
|
requestedBy: "Gateway", createdBy: "user-7", currentNte: 100m));
|
|
await context.SaveChangesAsync();
|
|
var service = NewService(context);
|
|
|
|
var result = await service.ListAsync(UserWithRoles("Manager"), "Pending", null, 1, 25, CancellationToken.None);
|
|
|
|
var item = result.Items.Should().ContainSingle().Subject;
|
|
item.WorkOrderId.Should().Be(workOrder.Id);
|
|
item.WorkOrderNumber.Should().Be("WO-77");
|
|
item.WorkOrderSite.Should().Be("SITE-EAST");
|
|
item.WorkOrderService.Should().Be("HVAC");
|
|
item.VendorCompanyName.Should().Be("Gateway");
|
|
item.RequestedByName.Should().Be("Gateway");
|
|
item.RequestedAt.Should().Be(new DateTime(2026, 3, 2));
|
|
item.VendorReason.Should().Be("Scope grew");
|
|
item.Delta.Should().Be(300m);
|
|
item.Status.Should().Be("Pending");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task List_RequesterLabelFallsBackToCreatingUserName()
|
|
{
|
|
using var context = NewContext();
|
|
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC");
|
|
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
|
context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" });
|
|
await context.SaveChangesAsync();
|
|
context.DispatchUpliftRequests.Add(Request(
|
|
dispatch, "Pending", new DateTime(2026, 3, 2),
|
|
requestedBy: null, createdBy: "user-7"));
|
|
await context.SaveChangesAsync();
|
|
var service = NewService(context);
|
|
|
|
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
|
|
|
result.Items.Single().RequestedByName.Should().Be("Ada Smith");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task List_MapsEvidenceAttachmentSummary()
|
|
{
|
|
using var context = NewContext();
|
|
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC");
|
|
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
|
var evidence = new VendorCompletionDocument
|
|
{
|
|
VendorId = vendor.Id,
|
|
DispatchId = dispatch.Id,
|
|
WorkOrderId = workOrder.Id,
|
|
OriginalFileName = "quote.pdf",
|
|
StoredFileName = "evidence.bin",
|
|
ContentType = "application/pdf",
|
|
SizeBytes = 512,
|
|
ScanStatus = "Passed",
|
|
ReviewStatus = "Approved",
|
|
Purpose = "UpliftEvidence",
|
|
Version = 1
|
|
};
|
|
context.VendorCompletionDocuments.Add(evidence);
|
|
await context.SaveChangesAsync();
|
|
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
|
{
|
|
DispatchId = dispatch.Id,
|
|
Status = "Pending",
|
|
CreatedDate = new DateTime(2026, 3, 2),
|
|
RequiredTier = 1,
|
|
RequestedNTE = 200m,
|
|
EvidenceDocumentId = evidence.Id
|
|
});
|
|
await context.SaveChangesAsync();
|
|
var service = NewService(context);
|
|
|
|
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
|
|
|
var item = result.Items.Single();
|
|
item.EvidenceDocumentId.Should().Be(evidence.Id);
|
|
item.EvidenceFileName.Should().Be("quote.pdf");
|
|
item.EvidenceContentType.Should().Be("application/pdf");
|
|
item.EvidenceSizeBytes.Should().Be(512);
|
|
}
|
|
|
|
// --- Queue contract fields (SH-208/SH-213) ---
|
|
|
|
[Fact]
|
|
public async Task List_MarksWorkOrderClosed_OnlyForTerminalLifecycle()
|
|
{
|
|
using var context = NewContext();
|
|
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
|
|
var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Plumbing");
|
|
workOrderA.LifecycleStatus = LifecycleStatus.Completed;
|
|
workOrderB.LifecycleStatus = LifecycleStatus.Canceled;
|
|
await context.SaveChangesAsync();
|
|
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
|
|
var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B");
|
|
var (vendorC, workOrderC) = await SeedWorkOrderAsync(context, "WO-C", "SITE-C", "Electrical");
|
|
var dispatchC = await SeedDispatchAsync(context, vendorC, workOrderC, "DIS-C");
|
|
context.DispatchUpliftRequests.AddRange(
|
|
Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)),
|
|
Request(dispatchB, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4)),
|
|
Request(dispatchC, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6)));
|
|
await context.SaveChangesAsync();
|
|
var service = NewService(context);
|
|
|
|
var result = await service.ListAsync(UserWithRoles("Admin"), "Approved", null, 1, 25, CancellationToken.None);
|
|
|
|
result.Items.Single(i => i.WorkOrderNumber == "WO-A").WorkOrderClosed.Should().BeTrue();
|
|
result.Items.Single(i => i.WorkOrderNumber == "WO-B").WorkOrderClosed.Should().BeTrue();
|
|
result.Items.Single(i => i.WorkOrderNumber == "WO-C").WorkOrderClosed.Should().BeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task List_ApprovedRow_CarriesDecidedByNameFromDecidingUser()
|
|
{
|
|
using var context = NewContext();
|
|
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
|
|
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A");
|
|
context.Users.Add(new ApplicationUser { Id = "user-9", FirstName = "Grace", LastName = "Hopper" });
|
|
await context.SaveChangesAsync();
|
|
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
|
{
|
|
DispatchId = dispatch.Id,
|
|
Status = "Approved",
|
|
CreatedDate = new DateTime(2026, 3, 1),
|
|
DecidedAt = new DateTime(2026, 3, 2),
|
|
DecidedByUserId = "user-9",
|
|
RequiredTier = 1,
|
|
RequestedNTE = 300m,
|
|
NotificationStatus = "Pending"
|
|
});
|
|
await context.SaveChangesAsync();
|
|
var service = NewService(context);
|
|
|
|
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
|
|
|
|
result.Items.Single().DecidedByName.Should().Be("Grace Hopper");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task List_AttachmentCount_CountsActiveDocumentsOnTheDispatch()
|
|
{
|
|
using var context = NewContext();
|
|
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
|
|
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A");
|
|
context.VendorCompletionDocuments.AddRange(
|
|
new VendorCompletionDocument
|
|
{
|
|
VendorId = vendor.Id, DispatchId = dispatch.Id, WorkOrderId = workOrder.Id,
|
|
OriginalFileName = "quote.pdf", StoredFileName = "a.bin", ContentType = "application/pdf",
|
|
SizeBytes = 512, ScanStatus = "Passed", ReviewStatus = "Approved", Purpose = "UpliftEvidence", Version = 1
|
|
},
|
|
new VendorCompletionDocument
|
|
{
|
|
VendorId = vendor.Id, DispatchId = dispatch.Id, WorkOrderId = workOrder.Id,
|
|
OriginalFileName = "photo.jpg", StoredFileName = "b.bin", ContentType = "image/jpeg",
|
|
SizeBytes = 2048, ScanStatus = "Passed", ReviewStatus = "Approved", Purpose = "Completion", Version = 1
|
|
},
|
|
new VendorCompletionDocument
|
|
{
|
|
VendorId = vendor.Id, DispatchId = dispatch.Id, WorkOrderId = workOrder.Id,
|
|
OriginalFileName = "old.pdf", StoredFileName = "c.bin", ContentType = "application/pdf",
|
|
SizeBytes = 128, ScanStatus = "Passed", ReviewStatus = "Approved", Purpose = "Completion", Version = 1,
|
|
IsDeleted = true
|
|
});
|
|
await context.SaveChangesAsync();
|
|
context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 3, 2)));
|
|
await context.SaveChangesAsync();
|
|
var service = NewService(context);
|
|
|
|
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
|
|
|
result.Items.Single().AttachmentCount.Should().Be(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task List_PendingExposureTotal_SumsGrantedAmountsAcrossAllPendingRequests()
|
|
{
|
|
using var context = NewContext();
|
|
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
|
|
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A");
|
|
context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" });
|
|
await context.SaveChangesAsync();
|
|
context.DispatchUpliftRequests.AddRange(
|
|
// Vendor-portal rows (createdby null) store the requested new NTE total,
|
|
// so the pending exposure is the delta: 400 - 100 = 300 and 600 - 250 = 350.
|
|
Request(dispatch, "Pending", new DateTime(2026, 3, 1), requested: 400m, currentNte: 100m),
|
|
Request(dispatch, "Pending", new DateTime(2026, 3, 2), requested: 600m, currentNte: 250m),
|
|
// Work-order-path rows (createdby set) store the requested increment: 90.
|
|
Request(dispatch, "Pending", new DateTime(2026, 3, 3), requested: 90m, currentNte: 600m, createdBy: "user-7"),
|
|
// Non-pending rows never add pending exposure.
|
|
Request(dispatch, "Approved", new DateTime(2026, 2, 1), decided: new DateTime(2026, 2, 2), requested: 500m, currentNte: 100m));
|
|
await context.SaveChangesAsync();
|
|
var service = NewService(context);
|
|
|
|
// Page 1 with a single row still reports the total across ALL pending rows.
|
|
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 1, CancellationToken.None);
|
|
|
|
result.Items.Should().HaveCount(1);
|
|
result.PendingExposureTotal.Should().Be(740m);
|
|
}
|
|
|
|
// --- Approved-on-WO exposure totals ---
|
|
|
|
[Fact]
|
|
public async Task List_AggregatesApprovedExposurePerWorkOrder_ExcludingNonApprovedStatuses()
|
|
{
|
|
using var context = NewContext();
|
|
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC", "Gateway-A");
|
|
var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Plumbing", "Gateway-B");
|
|
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
|
|
var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B");
|
|
context.DispatchUpliftRequests.AddRange(
|
|
Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 300m),
|
|
new DispatchUpliftRequest
|
|
{
|
|
DispatchId = dispatchA.Id,
|
|
Status = "NoApprovalRequired",
|
|
CreatedDate = new DateTime(2026, 3, 1),
|
|
RequiredTier = 0,
|
|
RequestedNTE = 150m
|
|
},
|
|
Request(dispatchA, "Pending", new DateTime(2026, 3, 1), requested: 999m),
|
|
Request(dispatchA, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 999m),
|
|
new DispatchUpliftRequest
|
|
{
|
|
DispatchId = dispatchA.Id,
|
|
Status = "Withdrawn",
|
|
CreatedDate = new DateTime(2026, 3, 1),
|
|
DecidedAt = new DateTime(2026, 3, 2),
|
|
RequiredTier = 1,
|
|
RequestedNTE = 999m
|
|
},
|
|
new DispatchUpliftRequest
|
|
{
|
|
DispatchId = dispatchA.Id,
|
|
Status = "Revoked",
|
|
CreatedDate = new DateTime(2026, 3, 1),
|
|
DecidedAt = new DateTime(2026, 3, 2),
|
|
RequiredTier = 1,
|
|
RequestedNTE = 999m
|
|
},
|
|
new DispatchUpliftRequest
|
|
{
|
|
DispatchId = dispatchA.Id,
|
|
Status = "Expired",
|
|
CreatedDate = new DateTime(2026, 3, 1),
|
|
RequiredTier = 1,
|
|
RequestedNTE = 999m
|
|
},
|
|
Request(dispatchB, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 75m));
|
|
await context.SaveChangesAsync();
|
|
var service = NewService(context);
|
|
|
|
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
|
|
|
|
var itemA = result.Items.Single(i => i.WorkOrderNumber == "WO-A");
|
|
itemA.WorkOrderAutoApprovedTotal.Should().Be(150m);
|
|
itemA.WorkOrderAdminApprovedTotal.Should().Be(300m);
|
|
itemA.WorkOrderApprovedExposureTotal.Should().Be(450m);
|
|
|
|
var itemB = result.Items.Single(i => i.WorkOrderNumber == "WO-B");
|
|
itemB.WorkOrderAutoApprovedTotal.Should().Be(0m);
|
|
itemB.WorkOrderAdminApprovedTotal.Should().Be(75m);
|
|
itemB.WorkOrderApprovedExposureTotal.Should().Be(75m);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Exposure_UsesGrantedAmountForVendorPortalAndRequestedAmountForWorkOrderRequests()
|
|
{
|
|
using var context = NewContext();
|
|
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-ORIGIN", "SITE-A", "HVAC");
|
|
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-ORIGIN");
|
|
context.DispatchUpliftRequests.AddRange(
|
|
Request(
|
|
dispatch,
|
|
"Approved",
|
|
new DateTime(2026, 3, 1),
|
|
decided: new DateTime(2026, 3, 2),
|
|
requested: 400m,
|
|
currentNte: 100m),
|
|
Request(
|
|
dispatch,
|
|
"Approved",
|
|
new DateTime(2026, 3, 3),
|
|
decided: new DateTime(2026, 3, 4),
|
|
requested: 250m,
|
|
createdBy: "internal-user",
|
|
currentNte: 100m));
|
|
await context.SaveChangesAsync();
|
|
|
|
var exposure = await new UpliftDataService(context)
|
|
.GetApprovedExposureForWorkOrdersAsync(new[] { workOrder.Id }, CancellationToken.None);
|
|
|
|
exposure.Should().ContainSingle().Which.AdminApprovedTotal.Should().Be(550m);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Exposure_IncludesLinkedDispatchesViaServerDerivedWorkOrderLinks()
|
|
{
|
|
using var context = NewContext();
|
|
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
|
|
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
|
|
var linkedDispatch = new Dispatch
|
|
{
|
|
VendorId = vendorA.Id,
|
|
DispatchNumber = "DIS-LINKED",
|
|
Status = "Completed"
|
|
};
|
|
context.Dispatches.Add(linkedDispatch);
|
|
await context.SaveChangesAsync();
|
|
context.DispatchWorkOrders.Add(new DispatchWorkOrder
|
|
{
|
|
DispatchId = linkedDispatch.Id,
|
|
WorkOrderId = workOrderA.Id
|
|
});
|
|
await context.SaveChangesAsync();
|
|
context.DispatchUpliftRequests.Add(Request(
|
|
linkedDispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 60m));
|
|
await context.SaveChangesAsync();
|
|
var data = new UpliftDataService(context);
|
|
|
|
var exposure = await data.GetApprovedExposureForWorkOrdersAsync(
|
|
new[] { workOrderA.Id }, CancellationToken.None);
|
|
|
|
var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrderA.Id).Subject;
|
|
total.AdminApprovedTotal.Should().Be(60m);
|
|
total.AutoApprovedTotal.Should().Be(0m);
|
|
}
|
|
|
|
// --- Read authorization (tier roles) ---
|
|
|
|
[Fact]
|
|
public async Task List_CanDecide_ReflectsTierRolesForTheCaller()
|
|
{
|
|
using var context = NewContext();
|
|
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC");
|
|
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
|
context.DispatchUpliftRequests.AddRange(
|
|
Request(dispatch, "Pending", new DateTime(2026, 3, 1), tier: 1),
|
|
Request(dispatch, "Pending", new DateTime(2026, 3, 2), tier: 2));
|
|
await context.SaveChangesAsync();
|
|
var service = NewService(context);
|
|
|
|
var tier1Only = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
|
var tier2User = await service.ListAsync(UserWithRoles("Manager"), "Pending", null, 1, 25, CancellationToken.None);
|
|
|
|
tier1Only.Items.Single(i => i.RequiredTier == 1).CanDecide.Should().BeTrue();
|
|
tier1Only.Items.Single(i => i.RequiredTier == 2).CanDecide.Should().BeFalse();
|
|
tier2User.Items.Single(i => i.RequiredTier == 2).CanDecide.Should().BeTrue();
|
|
tier2User.Items.Single(i => i.RequiredTier == 1).CanDecide.Should().BeFalse();
|
|
}
|
|
}
|