2026-09-16 20:03:35 -03:00
|
|
|
using Data.SeaHavenIndustries;
|
2026-09-16 22:48:47 -03:00
|
|
|
using Data.SeaHavenIndustries.Enums;
|
2026-09-16 20:03:35 -03:00
|
|
|
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
|
|
|
|
|
{
|
2026-09-16 22:19:43 -03:00
|
|
|
InternalWONumber = number,
|
2026-09-16 22:17:59 -03:00
|
|
|
WorkerOrderNumber = $"legacy-{number}",
|
2026-09-16 20:03:35 -03:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 03:47:09 -03:00
|
|
|
[Fact]
|
|
|
|
|
public async Task List_ResolvesWorkOrderContext_ForDispatchLinkedOnlyThroughDispatchWorkOrders()
|
|
|
|
|
{
|
|
|
|
|
// A dispatch can carry no primary WorkOrderId yet be linked to a work order
|
|
|
|
|
// through DispatchWorkOrders (the supported shape exercised by
|
|
|
|
|
// Exposure_IncludesLinkedDispatchesViaServerDerivedWorkOrderLinks). The queue
|
|
|
|
|
// read must resolve the same effective work order the sibling reads use, or the
|
|
|
|
|
// Approved tab surfaces blank WO context, workOrderClosed:false, and zero
|
|
|
|
|
// exposure on a terminal work order.
|
|
|
|
|
using var context = NewContext();
|
|
|
|
|
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-LINKED", "SITE-Z", "Electrical");
|
|
|
|
|
workOrder.LifecycleStatus = LifecycleStatus.Completed;
|
|
|
|
|
var linkedDispatch = new Dispatch
|
|
|
|
|
{
|
|
|
|
|
VendorId = vendor.Id,
|
|
|
|
|
DispatchNumber = "DIS-LINKED",
|
|
|
|
|
Status = "Completed"
|
|
|
|
|
};
|
|
|
|
|
context.Dispatches.Add(linkedDispatch);
|
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
|
context.DispatchWorkOrders.Add(new DispatchWorkOrder
|
|
|
|
|
{
|
|
|
|
|
DispatchId = linkedDispatch.Id,
|
|
|
|
|
WorkOrderId = workOrder.Id
|
|
|
|
|
});
|
|
|
|
|
context.DispatchUpliftRequests.Add(Request(
|
|
|
|
|
linkedDispatch, "Approved", new DateTime(2026, 3, 1),
|
|
|
|
|
decided: new DateTime(2026, 3, 2), requested: 90m));
|
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
|
var service = NewService(context);
|
|
|
|
|
|
|
|
|
|
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
var item = result.Items.Should().ContainSingle().Subject;
|
|
|
|
|
item.WorkOrderId.Should().Be(workOrder.Id);
|
|
|
|
|
item.WorkOrderNumber.Should().Be("WO-LINKED");
|
|
|
|
|
item.WorkOrderSite.Should().Be("SITE-Z");
|
|
|
|
|
item.WorkOrderService.Should().Be("Electrical");
|
|
|
|
|
item.WorkOrderClosed.Should().BeTrue();
|
|
|
|
|
item.WorkOrderApprovedExposureTotal.Should().Be(90m);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 22:19:43 -03:00
|
|
|
[Fact]
|
|
|
|
|
public async Task List_WorkOrderNumber_RendersInternalShNumber_NotTheCrmExternalId()
|
|
|
|
|
{
|
|
|
|
|
using var context = NewContext();
|
|
|
|
|
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
|
|
|
|
// Synced work orders carry the CRM external id in WorkerOrderNumber; the SH
|
|
|
|
|
// display number the board renders is stamped on InternalWONumber.
|
|
|
|
|
var workOrder = new WorkOrder
|
|
|
|
|
{
|
|
|
|
|
InternalWONumber = "10000000001",
|
|
|
|
|
WorkerOrderNumber = "CRM-EXT-77",
|
|
|
|
|
SiteCode = "SITE-EAST",
|
|
|
|
|
Service = "HVAC",
|
|
|
|
|
WorkerOrderTitle = "Repair"
|
|
|
|
|
};
|
|
|
|
|
context.AddRange(vendor, workOrder);
|
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
|
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
var item = result.Items.Should().ContainSingle().Subject;
|
|
|
|
|
item.WorkOrderNumber.Should().Be("10000000001");
|
|
|
|
|
item.WorkOrderNumber.Should().NotBe("CRM-EXT-77");
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 20:03:35 -03:00
|
|
|
[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 22:38:23 -03:00
|
|
|
// --- 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_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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 20:03:35 -03:00
|
|
|
// --- 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(
|
2026-09-16 22:19:43 -03:00
|
|
|
// Vendor-portal row (createdby null): stores the requested new NTE total,
|
|
|
|
|
// so the granted amount is 400 - 100 = 300.
|
|
|
|
|
Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 400m, currentNte: 100m),
|
2026-09-16 20:03:35 -03:00
|
|
|
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
|
|
|
|
|
},
|
2026-09-16 22:19:43 -03:00
|
|
|
Request(dispatchB, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 75m, currentNte: 0m));
|
2026-09-16 20:03:35 -03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 22:17:59 -03:00
|
|
|
[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 20:03:35 -03:00
|
|
|
[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 22:19:43 -03:00
|
|
|
[Fact]
|
|
|
|
|
public async Task Exposure_AdminApprovedSumsGrantedAmountsPerCreationPath()
|
|
|
|
|
{
|
|
|
|
|
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.DispatchUpliftRequests.AddRange(
|
|
|
|
|
// Vendor-portal rows (createdby null) store the requested new NTE total:
|
|
|
|
|
// 1000 -> 1500 grants 500, then 1500 -> 1800 grants 300.
|
|
|
|
|
Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 1500m, currentNte: 1000m),
|
|
|
|
|
Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 1800m, currentNte: 1500m),
|
|
|
|
|
// Work-order-path rows (createdby set) store the granted increment: 200.
|
|
|
|
|
Request(dispatch, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 200m, currentNte: 1800m, createdBy: "user-7"));
|
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
|
var data = new UpliftDataService(context);
|
|
|
|
|
|
|
|
|
|
var exposure = await data.GetApprovedExposureForWorkOrdersAsync(
|
|
|
|
|
new[] { workOrder.Id }, CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
// Granted exposure is 500 + 300 + 200; summing raw RequestedNTE would
|
|
|
|
|
// double-count whole NTE totals and report 3500.
|
|
|
|
|
var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrder.Id).Subject;
|
|
|
|
|
total.AdminApprovedTotal.Should().Be(1000m);
|
|
|
|
|
total.AutoApprovedTotal.Should().Be(0m);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 12:55:11 -03:00
|
|
|
[Fact]
|
|
|
|
|
public async Task Exposure_AutoApprovedSumsGrantedAmountsPerCreationPath()
|
|
|
|
|
{
|
|
|
|
|
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.DispatchUpliftRequests.AddRange(
|
|
|
|
|
// Vendor-portal rows (createdby null) store the requested new NTE total:
|
|
|
|
|
// 1000 -> 1500 grants 500, then 1500 -> 1800 grants 300.
|
|
|
|
|
Request(dispatch, "NoApprovalRequired", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 1500m, currentNte: 1000m),
|
|
|
|
|
Request(dispatch, "NoApprovalRequired", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 1800m, currentNte: 1500m),
|
|
|
|
|
// Work-order-path rows (createdby set) store the granted increment: 200.
|
|
|
|
|
Request(dispatch, "NoApprovalRequired", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 200m, currentNte: 1800m, createdBy: "user-7"));
|
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
|
var data = new UpliftDataService(context);
|
|
|
|
|
|
|
|
|
|
var exposure = await data.GetApprovedExposureForWorkOrdersAsync(
|
|
|
|
|
new[] { workOrder.Id }, CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
// Granted exposure is 500 + 300 + 200; summing raw RequestedNTE would
|
|
|
|
|
// double-count whole NTE totals and report 3500.
|
|
|
|
|
var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrder.Id).Subject;
|
|
|
|
|
total.AutoApprovedTotal.Should().Be(1000m);
|
|
|
|
|
total.AdminApprovedTotal.Should().Be(0m);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 20:03:35 -03:00
|
|
|
// --- 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();
|
|
|
|
|
}
|
2026-09-16 22:48:47 -03:00
|
|
|
|
|
|
|
|
// --- Closed-work-order flag and decider (SH-208) ---
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task List_MarksWorkOrderClosedOnlyForTerminalLifecycleStatuses()
|
|
|
|
|
{
|
|
|
|
|
using var context = NewContext();
|
|
|
|
|
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-OPEN", "SITE-A", "HVAC");
|
|
|
|
|
var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-DONE", "SITE-B", "HVAC");
|
|
|
|
|
var (vendorC, workOrderC) = await SeedWorkOrderAsync(context, "WO-CANCEL", "SITE-C", "HVAC");
|
|
|
|
|
workOrderA.LifecycleStatus = LifecycleStatus.InProgress;
|
|
|
|
|
workOrderB.LifecycleStatus = LifecycleStatus.Completed;
|
|
|
|
|
workOrderC.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 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, 1), decided: new DateTime(2026, 3, 2)),
|
|
|
|
|
Request(dispatchC, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)));
|
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
|
var service = NewService(context);
|
|
|
|
|
|
|
|
|
|
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
result.Items.Single(i => i.WorkOrderNumber == "WO-OPEN").WorkOrderClosed.Should().BeFalse();
|
|
|
|
|
result.Items.Single(i => i.WorkOrderNumber == "WO-DONE").WorkOrderClosed.Should().BeTrue();
|
|
|
|
|
result.Items.Single(i => i.WorkOrderNumber == "WO-CANCEL").WorkOrderClosed.Should().BeTrue();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task List_MapsDecidedByNameFromTheDecidingUser()
|
|
|
|
|
{
|
|
|
|
|
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-9", FirstName = "Grace", LastName = "Hopper" });
|
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
|
var request = Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2));
|
|
|
|
|
request.DecidedByUserId = "user-9";
|
|
|
|
|
context.DispatchUpliftRequests.Add(request);
|
|
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Attachment count (SH-207) ---
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task List_CountsOnlyNonDeletedUpliftEvidenceAttachments()
|
|
|
|
|
{
|
|
|
|
|
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.VendorCompletionDocuments.AddRange(
|
|
|
|
|
Document(dispatch, vendor, workOrder, "quote.pdf"),
|
|
|
|
|
Document(dispatch, vendor, workOrder, "photo.jpg"),
|
|
|
|
|
Document(dispatch, vendor, workOrder, "signed-completion.pdf", purpose: "Completion"),
|
|
|
|
|
Document(dispatch, vendor, workOrder, "deleted-estimate.pdf", isDeleted: true));
|
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
|
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
|
|
|
|
{
|
|
|
|
|
DispatchId = dispatch.Id,
|
|
|
|
|
Status = "Pending",
|
|
|
|
|
CreatedDate = new DateTime(2026, 3, 2),
|
|
|
|
|
RequiredTier = 1,
|
|
|
|
|
RequestedNTE = 200m
|
|
|
|
|
});
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static VendorCompletionDocument Document(
|
|
|
|
|
Dispatch dispatch,
|
|
|
|
|
Vendor vendor,
|
|
|
|
|
WorkOrder workOrder,
|
|
|
|
|
string fileName,
|
|
|
|
|
string purpose = "UpliftEvidence",
|
|
|
|
|
bool isDeleted = false) => new()
|
2026-09-17 00:08:42 -03:00
|
|
|
{
|
|
|
|
|
VendorId = vendor.Id,
|
|
|
|
|
DispatchId = dispatch.Id,
|
|
|
|
|
WorkOrderId = workOrder.Id,
|
|
|
|
|
OriginalFileName = fileName,
|
|
|
|
|
StoredFileName = $"{fileName}.bin",
|
|
|
|
|
ContentType = "application/octet-stream",
|
|
|
|
|
SizeBytes = 128,
|
|
|
|
|
ScanStatus = "Passed",
|
|
|
|
|
ReviewStatus = "Approved",
|
|
|
|
|
Purpose = purpose,
|
|
|
|
|
Version = 1,
|
|
|
|
|
IsDeleted = isDeleted
|
|
|
|
|
};
|
2026-09-16 22:48:47 -03:00
|
|
|
|
|
|
|
|
// --- Queue-wide pending exposure (SH-207) ---
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task List_PendingExposureTotal_SumsPendingRequestsAcrossTheQueueRegardlessOfFilter()
|
|
|
|
|
{
|
|
|
|
|
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", "HVAC");
|
|
|
|
|
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
|
|
|
|
|
var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B");
|
|
|
|
|
context.DispatchUpliftRequests.AddRange(
|
|
|
|
|
Request(dispatchA, "Pending", new DateTime(2026, 3, 1), requested: 125m),
|
|
|
|
|
Request(dispatchB, "Pending", new DateTime(2026, 3, 2), requested: 75m),
|
|
|
|
|
Request(dispatchA, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 999m),
|
|
|
|
|
new DispatchUpliftRequest
|
|
|
|
|
{
|
|
|
|
|
DispatchId = dispatchA.Id,
|
|
|
|
|
Status = "Pending",
|
|
|
|
|
CreatedDate = new DateTime(2026, 3, 5),
|
|
|
|
|
RequiredTier = 1,
|
|
|
|
|
RequestedNTE = 500m,
|
|
|
|
|
IsDeleted = true
|
|
|
|
|
});
|
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
|
var service = NewService(context);
|
|
|
|
|
|
|
|
|
|
// Even when the caller reads the Approved decision log, the header total
|
|
|
|
|
// still reflects the whole pending queue.
|
|
|
|
|
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
result.PendingExposureTotal.Should().Be(200m);
|
|
|
|
|
}
|
2026-09-16 20:03:35 -03:00
|
|
|
}
|