shoc-backend/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs
Alexandre Brandizzi d33ba34db9 fix(vendor-portal): vendors revise only uplift requests they raised
A work-order request stores the requested increase, not a total. Letting
the vendor revise one after changes were requested rewrote RequestedNTE
as a total while it still read as a work-order request, corrupting its
amount and the NTE it would be approved to. Revise now answers not-found
for any request the vendor did not raise and leaves the row untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 02:35:06 -03:00

313 lines
13 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()));
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);
}
[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)
{
}
}
}