shoc-backend/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs
2026-09-16 22:19:43 -03:00

523 lines
23 KiB
C#

using Data.SeaHavenIndustries;
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 = 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_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");
}
[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);
}
// --- 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(
// 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),
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, currentNte: 0m));
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_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);
}
[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);
}
// --- 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();
}
}