mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-03 05:43:31 +00:00
The route contract keeps the poc endpoint from main and the uplift evidence routes from this branch. Create still passes the scanned evidence document through the locked mutation.
337 lines
14 KiB
C#
337 lines
14 KiB
C#
using Data.SeaHavenIndustries;
|
|
using Data.SeaHavenIndustries.Enums;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
using SeaHaven.DataServices.Helpers;
|
|
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 SeaHavenIndustries.Tests;
|
|
|
|
// An uplift's amount is the NTE increase being requested. Work-order requests store
|
|
// that increase in RequestedNTE; vendor-portal requests store the requested NTE total.
|
|
// These tests walk each path through the approvals queue, approve and revoke, and the
|
|
// queue-wide and per-work-order exposure totals.
|
|
public sealed class UpliftAmountPerCreationPathTests
|
|
{
|
|
private const string DispatcherId = "dispatcher-1";
|
|
private const int WorkOrderId = 1;
|
|
private const int PrimaryDispatchId = 10;
|
|
private const int SecondDispatchId = 11;
|
|
|
|
private static ApplicationDbContext CreateContext() =>
|
|
new(new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options);
|
|
|
|
private static ApprovalsOptions NewOptions() => new()
|
|
{
|
|
UpliftTier1MaxUsd = 2500m,
|
|
Tier1Roles = new[] { "Approver" },
|
|
Tier2Roles = new[] { "Manager" },
|
|
};
|
|
|
|
private static WorkOrderUpliftService NewWorkOrderUpliftService(ApplicationDbContext context) =>
|
|
new(new UpliftDataService(context),
|
|
new DispatchDataService(context),
|
|
new WorkOrderDetailDataService(context),
|
|
WorkOrderAccountTestHelpers.Resolver(context),
|
|
new UserDataService(context),
|
|
new TeamPermissionOverrideDataService(context),
|
|
new TeamPermissionPolicy(),
|
|
TimeProvider.System,
|
|
Options.Create(NewOptions()),
|
|
new VendorDocumentDataService(context));
|
|
|
|
private static UpliftService NewQueueService(ApplicationDbContext context) =>
|
|
new(new UpliftDataService(context),
|
|
new DispatchDataService(context),
|
|
new NoDocumentStorage(),
|
|
TimeProvider.System,
|
|
Options.Create(NewOptions()));
|
|
|
|
private static ClaimsPrincipal Admin(string userId = DispatcherId) =>
|
|
WorkOrderAccountTestHelpers.OrgWideAdmin(userId);
|
|
|
|
private static Dispatch DispatchOf(ApplicationDbContext context, int id) =>
|
|
context.Dispatches.Single(d => d.Id == id);
|
|
|
|
// A work order with a 600 NTE on its primary dispatch, and a second dispatch on the
|
|
// same work order, also at 600.
|
|
private static async Task SeedWorkOrderAsync(ApplicationDbContext context)
|
|
{
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
|
|
context.Users.Add(new ApplicationUser
|
|
{
|
|
Id = DispatcherId,
|
|
UserName = DispatcherId,
|
|
FirstName = "Alex",
|
|
LastName = "Dispatcher",
|
|
});
|
|
var role = new IdentityRole("Admin");
|
|
context.Roles.Add(role);
|
|
context.UserRoles.Add(new IdentityUserRole<string> { UserId = DispatcherId, RoleId = role.Id });
|
|
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
|
|
context.Dispatches.AddRange(
|
|
new Dispatch
|
|
{
|
|
Id = PrimaryDispatchId,
|
|
VendorId = 1,
|
|
WorkOrderId = WorkOrderId,
|
|
NTEAmount = 600m,
|
|
DispatchNumber = "DIS-10",
|
|
Status = "Completed",
|
|
},
|
|
new Dispatch
|
|
{
|
|
Id = SecondDispatchId,
|
|
VendorId = 1,
|
|
WorkOrderId = WorkOrderId,
|
|
NTEAmount = 600m,
|
|
DispatchNumber = "DIS-11",
|
|
Status = "Completed",
|
|
});
|
|
context.workOrders.Add(new WorkOrder
|
|
{
|
|
Id = WorkOrderId,
|
|
InternalWONumber = "10000000001",
|
|
PrimaryDispatchId = PrimaryDispatchId,
|
|
AccountId = 1,
|
|
WorkOrderType = WorkOrderType.PM,
|
|
});
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
// Uses up the work order's auto-approval allowance, so a new work-order request
|
|
// waits for approval instead of auto-approving.
|
|
private static async Task ConsumeAutoApprovalAllowanceAsync(ApplicationDbContext context)
|
|
{
|
|
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
|
{
|
|
DispatchId = SecondDispatchId,
|
|
CurrentNTE = 100m,
|
|
RequestedNTE = 500m,
|
|
Status = "NoApprovalRequired",
|
|
RequiredTier = 0,
|
|
NotificationStatus = "Sent",
|
|
createdby = DispatcherId,
|
|
CreatedDate = new DateTime(2026, 1, 2),
|
|
});
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
private static async Task<int> CreatePendingWorkOrderRequestAsync(ApplicationDbContext context, decimal amount)
|
|
{
|
|
await ConsumeAutoApprovalAllowanceAsync(context);
|
|
var created = await NewWorkOrderUpliftService(context).CreateAsync(
|
|
WorkOrderId,
|
|
new CreateWorkOrderUpliftRequestDto { Amount = amount, Notes = "Extra parts" },
|
|
Admin(),
|
|
CancellationToken.None);
|
|
Assert.Equal("pending", created!.Status);
|
|
return created.Id;
|
|
}
|
|
|
|
// Stored exactly as the vendor portal writes it: no identity user, and RequestedNTE
|
|
// is the requested new NTE total.
|
|
private static async Task<int> SeedVendorRequestAsync(
|
|
ApplicationDbContext context, int dispatchId, decimal currentNte, decimal requestedTotal)
|
|
{
|
|
var request = new DispatchUpliftRequest
|
|
{
|
|
DispatchId = dispatchId,
|
|
CurrentNTE = currentNte,
|
|
RequestedNTE = requestedTotal,
|
|
Status = "Pending",
|
|
RequiredTier = 1,
|
|
RequestedByVendorName = "Acme HVAC",
|
|
NotificationStatus = "Sent",
|
|
CreatedDate = new DateTime(2026, 3, 1),
|
|
};
|
|
context.DispatchUpliftRequests.Add(request);
|
|
await context.SaveChangesAsync();
|
|
return request.Id;
|
|
}
|
|
|
|
private static Task<WorkOrderUpliftDto?> RevokeAsync(ApplicationDbContext context, int upliftId) =>
|
|
NewWorkOrderUpliftService(context).RevokeAsync(
|
|
WorkOrderId,
|
|
upliftId,
|
|
new RevokeWorkOrderUpliftRequestDto { Reason = "Scope reduced" },
|
|
Admin(),
|
|
CancellationToken.None);
|
|
|
|
[Fact]
|
|
public async Task WorkOrderRequest_QueueShowsTheRequestedIncrease()
|
|
{
|
|
await using var context = CreateContext();
|
|
await SeedWorkOrderAsync(context);
|
|
var upliftId = await CreatePendingWorkOrderRequestAsync(context, 90m);
|
|
|
|
var pending = await NewQueueService(context)
|
|
.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None);
|
|
|
|
Assert.Equal(90m, Assert.Single(pending.Items, i => i.Id == upliftId).Delta);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WorkOrderRequest_ApproveAddsIncreaseToNte_RevokeRestoresNte()
|
|
{
|
|
await using var context = CreateContext();
|
|
await SeedWorkOrderAsync(context);
|
|
var upliftId = await CreatePendingWorkOrderRequestAsync(context, 90m);
|
|
var queue = NewQueueService(context);
|
|
|
|
var approved = await queue.ApproveAsync(Admin(), upliftId, null, CancellationToken.None);
|
|
Assert.Equal(690m, approved.NTEAmount);
|
|
Assert.Equal(690m, DispatchOf(context, PrimaryDispatchId).NTEAmount);
|
|
|
|
var revoked = await RevokeAsync(context, upliftId);
|
|
Assert.Equal("revoked", revoked!.Status);
|
|
Assert.Equal(600m, DispatchOf(context, PrimaryDispatchId).NTEAmount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VendorRequest_QueueShowsIncrease_ApproveEndsAtRequestedTotal_RevokeRestoresNte()
|
|
{
|
|
await using var context = CreateContext();
|
|
await SeedWorkOrderAsync(context);
|
|
var upliftId = await SeedVendorRequestAsync(context, PrimaryDispatchId, currentNte: 600m, requestedTotal: 900m);
|
|
var queue = NewQueueService(context);
|
|
|
|
var pending = await queue.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None);
|
|
Assert.Equal(300m, Assert.Single(pending.Items).Delta);
|
|
|
|
var approved = await queue.ApproveAsync(Admin(), upliftId, null, CancellationToken.None);
|
|
Assert.Equal(900m, approved.NTEAmount);
|
|
Assert.Equal(900m, DispatchOf(context, PrimaryDispatchId).NTEAmount);
|
|
|
|
var revoked = await RevokeAsync(context, upliftId);
|
|
Assert.Equal("revoked", revoked!.Status);
|
|
Assert.Equal(600m, DispatchOf(context, PrimaryDispatchId).NTEAmount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PendingExposureAndApprovedOnWorkOrder_SumEachRequestsIncrease()
|
|
{
|
|
await using var context = CreateContext();
|
|
await SeedWorkOrderAsync(context);
|
|
var workOrderRequestId = await CreatePendingWorkOrderRequestAsync(context, 90m);
|
|
var vendorRequestId = await SeedVendorRequestAsync(context, SecondDispatchId, currentNte: 600m, requestedTotal: 900m);
|
|
var queue = NewQueueService(context);
|
|
|
|
var pending = await queue.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None);
|
|
Assert.Equal(390m, pending.PendingExposureTotal);
|
|
Assert.Equal(pending.Items.Sum(i => i.Delta), pending.PendingExposureTotal);
|
|
|
|
await queue.ApproveAsync(Admin(), workOrderRequestId, null, CancellationToken.None);
|
|
await queue.ApproveAsync(Admin(), vendorRequestId, null, CancellationToken.None);
|
|
|
|
var decided = await queue.ListAsync(Admin(), "Approved", null, 1, 25, CancellationToken.None);
|
|
var row = Assert.Single(decided.Items, i => i.Id == workOrderRequestId);
|
|
Assert.Equal(390m, row.WorkOrderAdminApprovedTotal);
|
|
// The earlier auto-approved work-order request counts its stored increase.
|
|
Assert.Equal(500m, row.WorkOrderAutoApprovedTotal);
|
|
Assert.Equal(0m, (await queue.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None)).PendingExposureTotal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AutoApprovedWorkOrderRequest_ConsumesAllowanceByItsRequestedIncrease()
|
|
{
|
|
await using var context = CreateContext();
|
|
await SeedWorkOrderAsync(context);
|
|
|
|
var created = await NewWorkOrderUpliftService(context).CreateAsync(
|
|
WorkOrderId,
|
|
new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Within limit" },
|
|
Admin(),
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal("auto_approved", created!.Status);
|
|
var stored = context.DispatchUpliftRequests.Single(u => u.Id == created.Id);
|
|
Assert.Equal(DispatcherId, stored.createdby);
|
|
Assert.Equal(400m, stored.RequestedNTE);
|
|
Assert.Equal(1000m, DispatchOf(context, PrimaryDispatchId).NTEAmount);
|
|
Assert.Equal(400m, await new UpliftDataService(context)
|
|
.SumAutoApprovedAmountForWorkOrderAsync(WorkOrderId, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WorkOrderUpliftList_ShowsEachRequestsIncrease()
|
|
{
|
|
await using var context = CreateContext();
|
|
await SeedWorkOrderAsync(context);
|
|
var workOrderRequestId = await CreatePendingWorkOrderRequestAsync(context, 90m);
|
|
var vendorRequestId = await SeedVendorRequestAsync(context, SecondDispatchId, currentNte: 600m, requestedTotal: 900m);
|
|
|
|
var uplifts = await NewWorkOrderUpliftService(context)
|
|
.ListAsync(WorkOrderId, Admin(), CancellationToken.None);
|
|
|
|
Assert.Equal(90m, Assert.Single(uplifts!, u => u.Id == workOrderRequestId).Amount);
|
|
Assert.Equal(300m, Assert.Single(uplifts!, u => u.Id == vendorRequestId).Amount);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(DispatcherId, 90, "$90.00")]
|
|
[InlineData(null, 900, "$300.00")]
|
|
public void NotificationDeltaRow_ShowsTheRequestedIncrease(string? createdBy, int requestedNte, string expectedDelta)
|
|
{
|
|
var dispatch = new Dispatch { Id = PrimaryDispatchId, DispatchNumber = "DIS-10", NTEAmount = 600m };
|
|
var request = new DispatchUpliftRequest
|
|
{
|
|
DispatchId = PrimaryDispatchId,
|
|
CurrentNTE = 600m,
|
|
RequestedNTE = requestedNte,
|
|
RequiredTier = 1,
|
|
createdby = createdBy,
|
|
};
|
|
|
|
// Builds the message content only; nothing is sent.
|
|
var (_, body) = UpliftNotificationMessage.BuildInitial(dispatch, request, "Acme HVAC", "https://example.test");
|
|
|
|
Assert.Contains(
|
|
$"<strong>Delta</strong></td><td style='padding:4px 10px;'>{expectedDelta}</td>",
|
|
body);
|
|
}
|
|
|
|
[Fact]
|
|
public void AmountDefinition_TranslatesToSqlServer_ForRowAndGroupedSums()
|
|
{
|
|
// ToQueryString only builds SQL; it never opens the connection.
|
|
using var context = new ApplicationDbContext(new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
.UseSqlServer("Server=translation-only;Database=none;Trusted_Connection=True;")
|
|
.Options);
|
|
|
|
var rowSql = context.DispatchUpliftRequests
|
|
.Select(UpliftAmount.ForQuery)
|
|
.ToQueryString();
|
|
var groupedSql = context.DispatchUpliftRequests
|
|
.GroupBy(u => new { u.DispatchId, u.Status }, UpliftAmount.ForQuery)
|
|
.Select(g => new { g.Key.DispatchId, g.Key.Status, Total = g.Sum() })
|
|
.ToQueryString();
|
|
|
|
Assert.Contains("CASE", rowSql);
|
|
Assert.Contains("[createdby] IS NULL", rowSql);
|
|
Assert.Contains("SUM(CASE", groupedSql);
|
|
Assert.Contains("GROUP BY", groupedSql);
|
|
}
|
|
|
|
private sealed class NoDocumentStorage : IVendorDocumentStoragePort
|
|
{
|
|
public Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken) =>
|
|
Task.CompletedTask;
|
|
|
|
public Stream OpenRead(int vendorId, int dispatchId, string storedFileName) =>
|
|
throw new NotSupportedException();
|
|
|
|
public void Delete(int vendorId, int dispatchId, string storedFileName)
|
|
{
|
|
}
|
|
}
|
|
}
|