mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
336 lines
14 KiB
C#
336 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()));
|
|
|
|
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)
|
|
{
|
|
}
|
|
}
|
|
}
|