mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-06 14:42:09 +00:00
Merge remote-tracking branch 'origin/main' into fix/ab/sh-403-reset-hardening-2
This commit is contained in:
commit
0298fc75bd
9 changed files with 789 additions and 13 deletions
276
Api.SeaHavenIndustries.Tests/UpliftRevokeEndpointRulesTests.cs
Normal file
276
Api.SeaHavenIndustries.Tests/UpliftRevokeEndpointRulesTests.cs
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Moq;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Revoke rules exercised end to end through both revoke endpoints (the Uplift
|
||||||
|
/// Approvals route and the work-order route) against the real services, so a
|
||||||
|
/// refusal is observed as the HTTP result and the unchanged stored state.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UpliftRevokeEndpointRulesTests
|
||||||
|
{
|
||||||
|
private const int WorkOrderId = 1;
|
||||||
|
private const int DispatchId = 10;
|
||||||
|
private const int AutoApprovedId = 100;
|
||||||
|
private const int AdminApprovedId = 101;
|
||||||
|
|
||||||
|
public enum RevokeRoute
|
||||||
|
{
|
||||||
|
UpliftApprovals,
|
||||||
|
WorkOrder,
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApplicationDbContext CreateContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClaimsPrincipal OrgWideUser(string userId, string role) =>
|
||||||
|
new(new ClaimsIdentity(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, userId),
|
||||||
|
new Claim(ClaimTypes.Role, role),
|
||||||
|
new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll),
|
||||||
|
},
|
||||||
|
"test"));
|
||||||
|
|
||||||
|
private static async Task SeedAsync(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
context.Accounts.Add(new Accounts { Id = 1, Name = "Acme Corp", IsDeleted = false });
|
||||||
|
context.Users.Add(new ApplicationUser { Id = "admin-1", UserName = "admin-1", FirstName = "Ada", LastName = "Admin" });
|
||||||
|
context.Users.Add(new ApplicationUser { Id = "dispatcher-1", UserName = "dispatcher-1", FirstName = "Dee", LastName = "Dispatcher" });
|
||||||
|
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
|
||||||
|
context.Dispatches.Add(new Dispatch
|
||||||
|
{
|
||||||
|
Id = DispatchId,
|
||||||
|
VendorId = 1,
|
||||||
|
WorkOrderId = WorkOrderId,
|
||||||
|
NTEAmount = 2200m,
|
||||||
|
DispatchNumber = "DIS-10",
|
||||||
|
Status = "Scheduled",
|
||||||
|
});
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = WorkOrderId,
|
||||||
|
InternalWONumber = "10000000001",
|
||||||
|
PrimaryDispatchId = DispatchId,
|
||||||
|
AccountId = 1,
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
});
|
||||||
|
// The admin filed this one themselves and it auto-approved within the allowance.
|
||||||
|
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||||
|
{
|
||||||
|
Id = AutoApprovedId,
|
||||||
|
DispatchId = DispatchId,
|
||||||
|
CurrentNTE = 1000m,
|
||||||
|
RequestedNTE = 400m,
|
||||||
|
Status = UpliftStatus.NoApprovalRequired,
|
||||||
|
RequiredTier = 0,
|
||||||
|
NotificationStatus = "Sent",
|
||||||
|
createdby = "admin-1",
|
||||||
|
CreatedDate = DateTime.UtcNow.AddHours(-2),
|
||||||
|
});
|
||||||
|
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||||
|
{
|
||||||
|
Id = AdminApprovedId,
|
||||||
|
DispatchId = DispatchId,
|
||||||
|
CurrentNTE = 1400m,
|
||||||
|
RequestedNTE = 800m,
|
||||||
|
Status = UpliftStatus.Approved,
|
||||||
|
RequiredTier = 1,
|
||||||
|
NotificationStatus = "Sent",
|
||||||
|
createdby = "dispatcher-1",
|
||||||
|
CreatedDate = DateTime.UtcNow.AddHours(-1),
|
||||||
|
DecidedAt = DateTime.UtcNow.AddMinutes(-30),
|
||||||
|
DecidedByUserId = "admin-1",
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderUpliftService NewWorkOrderUpliftService(ApplicationDbContext context) =>
|
||||||
|
new(
|
||||||
|
new UpliftDataService(context),
|
||||||
|
new DispatchDataService(context),
|
||||||
|
new WorkOrderDetailDataService(context),
|
||||||
|
new WorkOrderAccountResolver(new AccountDataService(context), new LocationDataService(context)),
|
||||||
|
new UserDataService(context),
|
||||||
|
new TeamPermissionOverrideDataService(context),
|
||||||
|
new TeamPermissionPolicy(),
|
||||||
|
TimeProvider.System,
|
||||||
|
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()));
|
||||||
|
|
||||||
|
private static async Task<IActionResult> RevokeAsync(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
RevokeRoute route,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
int upliftId,
|
||||||
|
string? reason)
|
||||||
|
{
|
||||||
|
var workOrderFlow = NewWorkOrderUpliftService(context);
|
||||||
|
var httpContext = new DefaultHttpContext { User = user };
|
||||||
|
|
||||||
|
if (route == RevokeRoute.WorkOrder)
|
||||||
|
{
|
||||||
|
var controller = new WorkOrderDetailController(
|
||||||
|
Mock.Of<IWorkOrderDetailService>(),
|
||||||
|
Mock.Of<IWorkOrderCommentService>(),
|
||||||
|
workOrderFlow,
|
||||||
|
Mock.Of<ILogger<WorkOrderDetailController>>())
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext { HttpContext = httpContext },
|
||||||
|
};
|
||||||
|
return await controller.RevokeUplift(
|
||||||
|
WorkOrderId,
|
||||||
|
upliftId,
|
||||||
|
new RevokeWorkOrderUpliftRequestDto { Reason = reason },
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
var upliftService = new UpliftService(
|
||||||
|
new UpliftDataService(context),
|
||||||
|
new DispatchDataService(context),
|
||||||
|
Mock.Of<IVendorDocumentStoragePort>(),
|
||||||
|
TimeProvider.System,
|
||||||
|
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
|
||||||
|
workOrderFlow);
|
||||||
|
var approvals = new UpliftController(upliftService, Mock.Of<ILogger<UpliftController>>())
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext { HttpContext = httpContext },
|
||||||
|
};
|
||||||
|
return await approvals.Revoke(
|
||||||
|
upliftId,
|
||||||
|
new UpliftController.DecisionRequest { Note = reason },
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AssertUnchangedAsync(ApplicationDbContext context, int upliftId, string status)
|
||||||
|
{
|
||||||
|
var stored = await context.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == upliftId);
|
||||||
|
stored.Status.Should().Be(status);
|
||||||
|
stored.DecisionNote.Should().BeNull();
|
||||||
|
(await context.Dispatches.AsNoTracking().SingleAsync(d => d.Id == DispatchId)).NTEAmount.Should().Be(2200m);
|
||||||
|
(await context.WorkOrderAuditLogs.AsNoTracking().AnyAsync(a => a.Action == "uplift_revoke")).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WorkOrderRoute_AdminRevokingAutoApprovedUpliftTheyRequested_IsForbiddenAndChangesNothing()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
await SeedAsync(context);
|
||||||
|
|
||||||
|
var result = await RevokeAsync(
|
||||||
|
context,
|
||||||
|
RevokeRoute.WorkOrder,
|
||||||
|
OrgWideUser("admin-1", "Admin"),
|
||||||
|
AutoApprovedId,
|
||||||
|
"Wrong quote attached");
|
||||||
|
|
||||||
|
var refused = result.Should().BeOfType<ObjectResult>().Subject;
|
||||||
|
refused.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||||
|
refused.Value.Should().BeOfType<Response>().Which.Message
|
||||||
|
.Should().StartWith("You are not authorized to perform this action");
|
||||||
|
await AssertUnchangedAsync(context, AutoApprovedId, UpliftStatus.NoApprovalRequired);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApprovalsRoute_AdminRevokingAutoApprovedUplift_IsRefusedAndChangesNothing()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
await SeedAsync(context);
|
||||||
|
|
||||||
|
var result = await RevokeAsync(
|
||||||
|
context,
|
||||||
|
RevokeRoute.UpliftApprovals,
|
||||||
|
OrgWideUser("admin-1", "Admin"),
|
||||||
|
AutoApprovedId,
|
||||||
|
"Wrong quote attached");
|
||||||
|
|
||||||
|
var refused = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||||
|
refused.Value.Should().BeOfType<Response>().Which.Message
|
||||||
|
.Should().StartWith("This uplift request cannot be revoked");
|
||||||
|
await AssertUnchangedAsync(context, AutoApprovedId, UpliftStatus.NoApprovalRequired);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(RevokeRoute.UpliftApprovals)]
|
||||||
|
[InlineData(RevokeRoute.WorkOrder)]
|
||||||
|
public async Task AdminRevokingAdminApprovedUpliftWithReason_Succeeds(RevokeRoute route)
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
await SeedAsync(context);
|
||||||
|
|
||||||
|
var result = await RevokeAsync(
|
||||||
|
context,
|
||||||
|
route,
|
||||||
|
OrgWideUser("admin-1", "Admin"),
|
||||||
|
AdminApprovedId,
|
||||||
|
" Approved against the wrong quote ");
|
||||||
|
|
||||||
|
result.Should().BeOfType<OkObjectResult>();
|
||||||
|
var stored = await context.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == AdminApprovedId);
|
||||||
|
stored.Status.Should().Be(UpliftStatus.Revoked);
|
||||||
|
stored.DecisionNote.Should().Be("Approved against the wrong quote");
|
||||||
|
(await context.Dispatches.AsNoTracking().SingleAsync(d => d.Id == DispatchId)).NTEAmount.Should().Be(1400m);
|
||||||
|
var audit = await context.WorkOrderAuditLogs.AsNoTracking().SingleAsync(a => a.Action == "uplift_revoke");
|
||||||
|
audit.OldValue.Should().Be(UpliftStatus.Approved);
|
||||||
|
audit.NewValue.Should().Be(UpliftStatus.Revoked);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(RevokeRoute.UpliftApprovals)]
|
||||||
|
[InlineData(RevokeRoute.WorkOrder)]
|
||||||
|
public async Task AdminRevokingAdminApprovedUpliftWithoutReason_IsRefused(RevokeRoute route)
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
await SeedAsync(context);
|
||||||
|
|
||||||
|
var result = await RevokeAsync(context, route, OrgWideUser("admin-1", "Admin"), AdminApprovedId, " ");
|
||||||
|
|
||||||
|
result.Should().BeOfType<BadRequestObjectResult>();
|
||||||
|
await AssertUnchangedAsync(context, AdminApprovedId, UpliftStatus.Approved);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WorkOrderRoute_DispatcherRevokingOwnAutoApprovedUpliftWithoutReason_Succeeds()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
await SeedAsync(context);
|
||||||
|
var own = await context.DispatchUpliftRequests.SingleAsync(u => u.Id == AutoApprovedId);
|
||||||
|
own.createdby = "dispatcher-1";
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await RevokeAsync(
|
||||||
|
context,
|
||||||
|
RevokeRoute.WorkOrder,
|
||||||
|
OrgWideUser("dispatcher-1", "Dispatcher"),
|
||||||
|
AutoApprovedId,
|
||||||
|
null);
|
||||||
|
|
||||||
|
result.Should().BeOfType<OkObjectResult>();
|
||||||
|
var stored = await context.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == AutoApprovedId);
|
||||||
|
stored.Status.Should().Be(UpliftStatus.Revoked);
|
||||||
|
stored.DecisionNote.Should().BeNull();
|
||||||
|
(await context.Dispatches.AsNoTracking().SingleAsync(d => d.Id == DispatchId)).NTEAmount.Should().Be(1000m);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
SeaHaven.DataServices/Helpers/LiveUpliftStatus.cs
Normal file
26
SeaHaven.DataServices/Helpers/LiveUpliftStatus.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
namespace SeaHaven.DataServices.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Which stored uplift statuses still count as a live uplift on a work order.
|
||||||
|
/// Cancelled (legacy "Cancelled", "Withdrawn", "Expired") and revoked uplifts do not;
|
||||||
|
/// pending, approved, auto-approved and rejected ones do. The advanced-search
|
||||||
|
/// "Has uplift" filter and the board Uplift column both use this, so they cannot drift.
|
||||||
|
/// </summary>
|
||||||
|
public static class LiveUpliftStatus
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Stored statuses that are not live. Kept as an array so EF translates
|
||||||
|
/// <c>NonLive.Contains(u.Status)</c> inside query predicates.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly string[] NonLive =
|
||||||
|
{
|
||||||
|
"Withdrawn",
|
||||||
|
"Cancelled",
|
||||||
|
"Expired",
|
||||||
|
"Revoked",
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool IsLive(string? status) =>
|
||||||
|
status == null || !NonLive.Contains(status, StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -238,11 +238,13 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var workOrderIds = workOrders.Select(w => w.Id);
|
var workOrderIds = workOrders.Select(w => w.Id);
|
||||||
|
var nonLive = LiveUpliftStatus.NonLive;
|
||||||
|
|
||||||
var live = context.DispatchUpliftRequests
|
var live = context.DispatchUpliftRequests
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(u =>
|
.Where(u =>
|
||||||
(u.IsDeleted == null || u.IsDeleted == false)
|
(u.IsDeleted == null || u.IsDeleted == false)
|
||||||
|
&& !nonLive.Contains(u.Status)
|
||||||
&& u.Dispatch != null
|
&& u.Dispatch != null
|
||||||
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false));
|
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,6 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
WorkOrderType.Reactive,
|
WorkOrderType.Reactive,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly string[] RevokedUpliftStatuses =
|
|
||||||
{
|
|
||||||
"Withdrawn",
|
|
||||||
"Cancelled",
|
|
||||||
};
|
|
||||||
|
|
||||||
public static IQueryable<WorkOrder> ApplySeverityFilter(
|
public static IQueryable<WorkOrder> ApplySeverityFilter(
|
||||||
IQueryable<WorkOrder> query,
|
IQueryable<WorkOrder> query,
|
||||||
IReadOnlyList<int>? severities)
|
IReadOnlyList<int>? severities)
|
||||||
|
|
@ -125,18 +119,21 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
if (!hasUplift)
|
if (!hasUplift)
|
||||||
return query;
|
return query;
|
||||||
|
|
||||||
|
// No sub-filter: any live uplift. An explicit sub-filter matches exactly the
|
||||||
|
// statuses it names, so "cancelled" / "revoked" still find those work orders.
|
||||||
var mappedStatuses = MapUpliftStatuses(upliftStatuses);
|
var mappedStatuses = MapUpliftStatuses(upliftStatuses);
|
||||||
|
var anyLive = mappedStatuses.Count == 0;
|
||||||
|
var nonLive = LiveUpliftStatus.NonLive;
|
||||||
|
|
||||||
return query.Where(w => context.DispatchUpliftRequests.Any(u =>
|
return query.Where(w => context.DispatchUpliftRequests.Any(u =>
|
||||||
(u.IsDeleted == null || u.IsDeleted == false)
|
(u.IsDeleted == null || u.IsDeleted == false)
|
||||||
&& u.Status != "Expired"
|
|
||||||
&& !RevokedUpliftStatuses.Contains(u.Status)
|
|
||||||
&& u.Dispatch != null
|
&& u.Dispatch != null
|
||||||
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
|
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
|
||||||
&& (
|
&& (
|
||||||
u.Dispatch.WorkOrderId == w.Id
|
u.Dispatch.WorkOrderId == w.Id
|
||||||
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id))
|
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id))
|
||||||
&& (mappedStatuses.Count == 0 || mappedStatuses.Contains(u.Status))));
|
&& ((anyLive && !nonLive.Contains(u.Status))
|
||||||
|
|| (!anyLive && mappedStatuses.Contains(u.Status)))));
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static List<string> MapUpliftStatuses(IReadOnlyList<string>? upliftStatuses)
|
internal static List<string> MapUpliftStatuses(IReadOnlyList<string>? upliftStatuses)
|
||||||
|
|
@ -166,6 +163,14 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
mapped.Add("Rejected");
|
mapped.Add("Rejected");
|
||||||
mapped.Add("Denied");
|
mapped.Add("Denied");
|
||||||
break;
|
break;
|
||||||
|
case "cancelled":
|
||||||
|
mapped.Add("Withdrawn");
|
||||||
|
mapped.Add("Cancelled");
|
||||||
|
mapped.Add("Expired");
|
||||||
|
break;
|
||||||
|
case "revoked":
|
||||||
|
mapped.Add("Revoked");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -273,6 +273,11 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
if (canonical == UpliftStatus.NoApprovalRequired)
|
if (canonical == UpliftStatus.NoApprovalRequired)
|
||||||
{
|
{
|
||||||
|
// An admin revoke overturns a human decision, and an auto-approval has none,
|
||||||
|
// so admins are refused here even when they filed the request themselves.
|
||||||
|
if (user.IsInRole("Admin"))
|
||||||
|
throw new UpliftForbiddenException("Admins can revoke only admin-approved uplifts");
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(userId)
|
if (string.IsNullOrWhiteSpace(userId)
|
||||||
|| !string.Equals(req.createdby, userId, StringComparison.Ordinal))
|
|| !string.Equals(req.createdby, userId, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -212,7 +212,7 @@ public sealed class WorkOrderUpliftDispatchOwnershipTests
|
||||||
row.Id,
|
row.Id,
|
||||||
created!.Id,
|
created!.Id,
|
||||||
new RevokeWorkOrderUpliftRequestDto(),
|
new RevokeWorkOrderUpliftRequestDto(),
|
||||||
WorkOrderAccountTestHelpers.AccountUser(),
|
WorkOrderAccountTestHelpers.AccountUser("actor-1", 1, "Dispatcher"),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal("revoked", revoked!.Status);
|
Assert.Equal("revoked", revoked!.Status);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
// Relational (SQLite) coverage for the live-uplift predicate. The in-memory provider
|
||||||
|
// evaluates LINQ in memory and cannot prove the "Has uplift" filter or the board uplift
|
||||||
|
// aggregate translate to SQL, so this runs the advanced search against a real provider,
|
||||||
|
// scoped to one account, with a live uplift on another account's work order.
|
||||||
|
public sealed class WorkOrderUpliftLiveStatusRelationalTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task AdvancedSearch_HasUplift_CountsOnlyLiveUpliftsWithinAccount()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("DataSource=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
await using var context = new SqliteLiveUpliftTestDbContext(options);
|
||||||
|
await context.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var accountA = new Accounts { Name = "Account A" };
|
||||||
|
var accountB = new Accounts { Name = "Account B" };
|
||||||
|
var vendor = new Vendor { CompanyName = "Acme HVAC", IsActive = true };
|
||||||
|
context.AddRange(accountA, accountB, vendor);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var revokedOnly = NewWorkOrder("WO-REVOKED", accountA.Id);
|
||||||
|
var cancelledOnly = NewWorkOrder("WO-CANCELLED", accountA.Id);
|
||||||
|
var revokedPlusPending = NewWorkOrder("WO-MIXED", accountA.Id);
|
||||||
|
var otherAccountPending = NewWorkOrder("WO-OTHER", accountB.Id);
|
||||||
|
context.AddRange(revokedOnly, cancelledOnly, revokedPlusPending, otherAccountPending);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var revokedDispatch = new Dispatch { VendorId = vendor.Id, DispatchNumber = "DIS-R", Status = "Scheduled" };
|
||||||
|
var cancelledDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = cancelledOnly.Id, DispatchNumber = "DIS-C", Status = "Scheduled" };
|
||||||
|
var mixedDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = revokedPlusPending.Id, DispatchNumber = "DIS-M", Status = "Scheduled" };
|
||||||
|
var mixedPendingDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = revokedPlusPending.Id, DispatchNumber = "DIS-M2", Status = "Scheduled" };
|
||||||
|
var otherDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = otherAccountPending.Id, DispatchNumber = "DIS-O", Status = "Scheduled" };
|
||||||
|
context.Dispatches.AddRange(revokedDispatch, cancelledDispatch, mixedDispatch, mixedPendingDispatch, otherDispatch);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
// The revoked-only work order reaches its uplift through a multi-WO dispatch link.
|
||||||
|
context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = revokedDispatch.Id, WorkOrderId = revokedOnly.Id });
|
||||||
|
context.DispatchUpliftRequests.AddRange(
|
||||||
|
NewUplift(revokedDispatch.Id, "Revoked", new DateTime(2026, 6, 20, 9, 0, 0)),
|
||||||
|
NewUplift(cancelledDispatch.Id, "Withdrawn", new DateTime(2026, 6, 20, 9, 0, 0)),
|
||||||
|
NewUplift(mixedDispatch.Id, "Revoked", new DateTime(2026, 6, 20, 9, 0, 0)),
|
||||||
|
NewUplift(mixedPendingDispatch.Id, "Pending", new DateTime(2026, 6, 20, 11, 0, 0)),
|
||||||
|
NewUplift(otherDispatch.Id, "Pending", new DateTime(2026, 6, 20, 9, 0, 0)));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var data = new WorkOrderAdvancedSearchDataService(context);
|
||||||
|
|
||||||
|
var hasUplift = await data.SearchAsync(Query(accountA.Id, hasUplift: true, upliftStatuses: null));
|
||||||
|
var row = Assert.Single(hasUplift.Rows);
|
||||||
|
Assert.Equal(revokedPlusPending.Id, row.Id);
|
||||||
|
Assert.True(row.HasUplift);
|
||||||
|
Assert.Equal("Pending", row.PrimaryUpliftStatus);
|
||||||
|
|
||||||
|
var revoked = await data.SearchAsync(Query(accountA.Id, hasUplift: true, upliftStatuses: new[] { "revoked" }));
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { revokedOnly.Id, revokedPlusPending.Id }.OrderBy(id => id),
|
||||||
|
revoked.Rows.Select(r => r.Id).OrderBy(id => id));
|
||||||
|
|
||||||
|
var all = await data.SearchAsync(Query(accountA.Id, hasUplift: false, upliftStatuses: null));
|
||||||
|
Assert.Equal(3, all.TotalCount);
|
||||||
|
Assert.DoesNotContain(all.Rows, r => r.Id == otherAccountPending.Id);
|
||||||
|
Assert.False(all.Rows.Single(r => r.Id == revokedOnly.Id).HasUplift);
|
||||||
|
Assert.Null(all.Rows.Single(r => r.Id == revokedOnly.Id).PrimaryUpliftStatus);
|
||||||
|
Assert.False(all.Rows.Single(r => r.Id == cancelledOnly.Id).HasUplift);
|
||||||
|
Assert.True(all.Rows.Single(r => r.Id == revokedPlusPending.Id).HasUplift);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrder NewWorkOrder(string number, int accountId) => new()
|
||||||
|
{
|
||||||
|
InternalWONumber = number,
|
||||||
|
WorkerOrderTitle = "Repair",
|
||||||
|
AccountId = accountId,
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 23, 8, 0, 0),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static DispatchUpliftRequest NewUplift(int dispatchId, string status, DateTime createdDate) => new()
|
||||||
|
{
|
||||||
|
DispatchId = dispatchId,
|
||||||
|
Status = status,
|
||||||
|
CreatedDate = createdDate,
|
||||||
|
RequiredTier = 1,
|
||||||
|
RequestedNTE = 750m,
|
||||||
|
NotificationStatus = "Pending",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static WorkOrderAdvancedSearchQuery Query(
|
||||||
|
int accountId,
|
||||||
|
bool hasUplift,
|
||||||
|
IReadOnlyList<string>? upliftStatuses) => new(
|
||||||
|
Search: null,
|
||||||
|
DateFrom: new DateOnly(2026, 6, 22),
|
||||||
|
DateTo: new DateOnly(2026, 6, 28),
|
||||||
|
UnscheduledOnly: false,
|
||||||
|
Sites: null,
|
||||||
|
Regions: null,
|
||||||
|
Types: null,
|
||||||
|
Overdue: false,
|
||||||
|
Dispatchers: null,
|
||||||
|
Statuses: null,
|
||||||
|
PmTypes: null,
|
||||||
|
VendorIds: null,
|
||||||
|
DocStatuses: null,
|
||||||
|
Severities: null,
|
||||||
|
Rescheduled: false,
|
||||||
|
CarriedOver: false,
|
||||||
|
AddOn: false,
|
||||||
|
AvetaOnly: false,
|
||||||
|
FlagColors: null,
|
||||||
|
InternalOnly: false,
|
||||||
|
HasUplift: hasUplift,
|
||||||
|
UpliftStatuses: upliftStatuses,
|
||||||
|
MyWorkOrders: false,
|
||||||
|
CurrentUserId: null,
|
||||||
|
Page: 0,
|
||||||
|
PageSize: 50,
|
||||||
|
SortBy: "scheduledDate",
|
||||||
|
SortDir: "asc",
|
||||||
|
AccountId: accountId);
|
||||||
|
|
||||||
|
private sealed class SqliteLiveUpliftTestDbContext : ApplicationDbContext
|
||||||
|
{
|
||||||
|
public SqliteLiveUpliftTestDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||||
|
: base(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(builder);
|
||||||
|
|
||||||
|
// SQL Server filtered index syntax is invalid on SQLite.
|
||||||
|
foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes()))
|
||||||
|
{
|
||||||
|
if (index.GetFilter() != null)
|
||||||
|
index.SetFilter(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQLite has no rowversion type; treat as plain nullable blobs.
|
||||||
|
foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) })
|
||||||
|
{
|
||||||
|
var property = builder.Entity(entityType).Property("RowVersion").Metadata;
|
||||||
|
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
|
||||||
|
property.IsConcurrencyToken = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
266
SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusTests.cs
Normal file
266
SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusTests.cs
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Helpers;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Has uplift" (advanced filter) and the board Uplift column count only live uplifts:
|
||||||
|
/// a work order whose uplifts were all cancelled, withdrawn, expired or revoked has none.
|
||||||
|
/// </summary>
|
||||||
|
public class WorkOrderUpliftLiveStatusTests
|
||||||
|
{
|
||||||
|
private static readonly DateOnly WeekStart = new(2026, 6, 22);
|
||||||
|
|
||||||
|
private const int WithdrawnOnly = 1;
|
||||||
|
private const int LegacyCancelledOnly = 2;
|
||||||
|
private const int RevokedOnlyViaLink = 3;
|
||||||
|
private const int RevokedPlusPending = 4;
|
||||||
|
private const int RejectedOnly = 5;
|
||||||
|
private const int ExpiredOnly = 6;
|
||||||
|
private const int NoUplift = 7;
|
||||||
|
private const int NewerRevokedOlderAutoApproved = 8;
|
||||||
|
|
||||||
|
private static readonly int[] LiveUpliftWorkOrders =
|
||||||
|
{
|
||||||
|
RevokedPlusPending,
|
||||||
|
RejectedOnly,
|
||||||
|
NewerRevokedOlderAutoApproved,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ApplicationDbContext CreateContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ApplicationDbContext> SeedAsync()
|
||||||
|
{
|
||||||
|
var context = CreateContext();
|
||||||
|
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
|
||||||
|
|
||||||
|
for (var id = WithdrawnOnly; id <= NewerRevokedOlderAutoApproved; id++)
|
||||||
|
{
|
||||||
|
// The revoked-only work order reaches its uplift through a multi-WO dispatch link.
|
||||||
|
var linkedOnly = id == RevokedOnlyViaLink;
|
||||||
|
context.Dispatches.Add(new Dispatch
|
||||||
|
{
|
||||||
|
Id = 10 + id,
|
||||||
|
VendorId = 1,
|
||||||
|
WorkOrderId = linkedOnly ? null : id,
|
||||||
|
});
|
||||||
|
if (linkedOnly)
|
||||||
|
context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = 10 + id, WorkOrderId = id });
|
||||||
|
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
InternalWONumber = $"1000000000{id}",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 23),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
PrimaryDispatchId = linkedOnly ? null : 10 + id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var older = new DateTime(2026, 6, 20, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
var newer = older.AddHours(2);
|
||||||
|
context.DispatchUpliftRequests.AddRange(
|
||||||
|
Uplift(101, WithdrawnOnly, "Withdrawn", older),
|
||||||
|
Uplift(102, LegacyCancelledOnly, "Cancelled", older),
|
||||||
|
Uplift(103, RevokedOnlyViaLink, "Revoked", older),
|
||||||
|
Uplift(104, RevokedPlusPending, "Revoked", older),
|
||||||
|
Uplift(105, RevokedPlusPending, "Pending", newer),
|
||||||
|
Uplift(106, RejectedOnly, "Rejected", older),
|
||||||
|
Uplift(107, ExpiredOnly, "Expired", older),
|
||||||
|
Uplift(108, NewerRevokedOlderAutoApproved, "NoApprovalRequired", older),
|
||||||
|
Uplift(109, NewerRevokedOlderAutoApproved, "Revoked", newer));
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DispatchUpliftRequest Uplift(int id, int workOrderId, string status, DateTime createdDate) => new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
DispatchId = 10 + workOrderId,
|
||||||
|
RequestedNTE = 1000m + id,
|
||||||
|
Status = status,
|
||||||
|
RequiredTier = 1,
|
||||||
|
NotificationStatus = "Pending",
|
||||||
|
CreatedDate = createdDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static async Task<int[]> SearchIdsAsync(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
bool hasUplift,
|
||||||
|
List<string>? upliftStatuses = null)
|
||||||
|
{
|
||||||
|
var service = new WorkOrderAdvancedSearchService(
|
||||||
|
new WorkOrderAdvancedSearchDataService(context),
|
||||||
|
WorkOrderAccountTestHelpers.Resolver(context));
|
||||||
|
|
||||||
|
var result = await service.SearchAsync(
|
||||||
|
new WorkOrderAdvancedSearchQueryDto
|
||||||
|
{
|
||||||
|
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
|
||||||
|
DateFrom = WeekStart,
|
||||||
|
DateTo = WeekStart.AddDays(6),
|
||||||
|
HasUplift = hasUplift,
|
||||||
|
UpliftStatuses = upliftStatuses,
|
||||||
|
PageSize = 50,
|
||||||
|
},
|
||||||
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
||||||
|
null);
|
||||||
|
|
||||||
|
return result.Items.Select(i => i.Id).OrderBy(id => id).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HasUplift_ExcludesWorkOrdersWhoseOnlyUpliftsAreCancelledOrRevoked()
|
||||||
|
{
|
||||||
|
await using var context = await SeedAsync();
|
||||||
|
|
||||||
|
var ids = await SearchIdsAsync(context, hasUplift: true);
|
||||||
|
|
||||||
|
Assert.Equal(LiveUpliftWorkOrders, ids);
|
||||||
|
Assert.DoesNotContain(WithdrawnOnly, ids);
|
||||||
|
Assert.DoesNotContain(LegacyCancelledOnly, ids);
|
||||||
|
Assert.DoesNotContain(RevokedOnlyViaLink, ids);
|
||||||
|
Assert.DoesNotContain(ExpiredOnly, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HasUplift_IncludesWorkOrderWithRevokedAndPendingUplift()
|
||||||
|
{
|
||||||
|
await using var context = await SeedAsync();
|
||||||
|
|
||||||
|
Assert.Contains(RevokedPlusPending, await SearchIdsAsync(context, hasUplift: true));
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { RevokedPlusPending },
|
||||||
|
await SearchIdsAsync(context, hasUplift: true, new List<string> { "pending" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HasUplift_RejectedUpliftStillCounts()
|
||||||
|
{
|
||||||
|
await using var context = await SeedAsync();
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { RejectedOnly },
|
||||||
|
await SearchIdsAsync(context, hasUplift: true, new List<string> { "rejected" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HasUplift_ExplicitCancelledStatus_FindsCancelledWithdrawnAndExpiredUplifts()
|
||||||
|
{
|
||||||
|
await using var context = await SeedAsync();
|
||||||
|
|
||||||
|
var ids = await SearchIdsAsync(context, hasUplift: true, new List<string> { "cancelled" });
|
||||||
|
|
||||||
|
Assert.Equal(new[] { WithdrawnOnly, LegacyCancelledOnly, ExpiredOnly }, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HasUplift_ExplicitRevokedStatus_FindsRevokedUpliftsOnPrimaryAndLinkedDispatches()
|
||||||
|
{
|
||||||
|
await using var context = await SeedAsync();
|
||||||
|
|
||||||
|
var ids = await SearchIdsAsync(context, hasUplift: true, new List<string> { "revoked" });
|
||||||
|
|
||||||
|
Assert.Equal(new[] { RevokedOnlyViaLink, RevokedPlusPending, NewerRevokedOlderAutoApproved }, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoardUpliftColumn_AgreesWithHasUpliftFilter()
|
||||||
|
{
|
||||||
|
await using var context = await SeedAsync();
|
||||||
|
var filtered = await SearchIdsAsync(context, hasUplift: true);
|
||||||
|
|
||||||
|
var boardService = new WorkOrderBoardService(
|
||||||
|
new WorkOrderBoardDataService(context),
|
||||||
|
WorkOrderAccountTestHelpers.Resolver(context));
|
||||||
|
var board = await boardService.GetBoardAsync(
|
||||||
|
new WorkOrderBoardQueryDto { WeekStart = WeekStart },
|
||||||
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
||||||
|
null);
|
||||||
|
|
||||||
|
Assert.Equal(8, board.Scheduled.Count);
|
||||||
|
foreach (var row in board.Scheduled)
|
||||||
|
{
|
||||||
|
Assert.Equal(filtered.Contains(row.Id), row.UpliftSummary!.HasUplift);
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchService = new WorkOrderAdvancedSearchService(
|
||||||
|
new WorkOrderAdvancedSearchDataService(context),
|
||||||
|
WorkOrderAccountTestHelpers.Resolver(context));
|
||||||
|
var unfiltered = await searchService.SearchAsync(
|
||||||
|
new WorkOrderAdvancedSearchQueryDto
|
||||||
|
{
|
||||||
|
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
|
||||||
|
DateFrom = WeekStart,
|
||||||
|
DateTo = WeekStart.AddDays(6),
|
||||||
|
PageSize = 50,
|
||||||
|
},
|
||||||
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
||||||
|
null);
|
||||||
|
Assert.Equal(8, unfiltered.Items.Count());
|
||||||
|
foreach (var item in unfiltered.Items)
|
||||||
|
{
|
||||||
|
Assert.Equal(filtered.Contains(item.Id), item.UpliftSummary!.HasUplift);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoardUpliftColumn_ShowsNewestLiveUpliftAndNoStatusForNonLiveOnly()
|
||||||
|
{
|
||||||
|
await using var context = await SeedAsync();
|
||||||
|
|
||||||
|
var boardService = new WorkOrderBoardService(
|
||||||
|
new WorkOrderBoardDataService(context),
|
||||||
|
WorkOrderAccountTestHelpers.Resolver(context));
|
||||||
|
var board = await boardService.GetBoardAsync(
|
||||||
|
new WorkOrderBoardQueryDto { WeekStart = WeekStart },
|
||||||
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
||||||
|
null);
|
||||||
|
var byId = board.Scheduled.ToDictionary(r => r.Id, r => r.UpliftSummary!);
|
||||||
|
|
||||||
|
foreach (var id in new[] { WithdrawnOnly, LegacyCancelledOnly, RevokedOnlyViaLink, ExpiredOnly, NoUplift })
|
||||||
|
{
|
||||||
|
Assert.False(byId[id].HasUplift);
|
||||||
|
Assert.Null(byId[id].PrimaryStatus);
|
||||||
|
Assert.Null(byId[id].Amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal("pending", byId[RevokedPlusPending].PrimaryStatus);
|
||||||
|
Assert.Equal(1, byId[RevokedPlusPending].PendingCount);
|
||||||
|
Assert.Equal("rejected", byId[RejectedOnly].PrimaryStatus);
|
||||||
|
Assert.Equal("auto_approved", byId[NewerRevokedOlderAutoApproved].PrimaryStatus);
|
||||||
|
Assert.Equal(1108m, byId[NewerRevokedOlderAutoApproved].Amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(UpliftStatus.Pending)]
|
||||||
|
[InlineData(UpliftStatus.Approved)]
|
||||||
|
[InlineData(UpliftStatus.Rejected)]
|
||||||
|
[InlineData(UpliftStatus.ChangesRequested)]
|
||||||
|
[InlineData(UpliftStatus.Withdrawn)]
|
||||||
|
[InlineData(UpliftStatus.Expired)]
|
||||||
|
[InlineData(UpliftStatus.NoApprovalRequired)]
|
||||||
|
[InlineData(UpliftStatus.Revoked)]
|
||||||
|
[InlineData(UpliftStatus.LegacyDenied)]
|
||||||
|
[InlineData(UpliftStatus.LegacyCancelled)]
|
||||||
|
public void LiveUpliftStatus_MatchesFrontendCancelledAndRevokedStatuses(string storedStatus)
|
||||||
|
{
|
||||||
|
var frontendStatus = WorkOrderUpliftContractMapper.ToFrontendStatus(storedStatus);
|
||||||
|
var expectedLive = frontendStatus is not ("cancelled" or "revoked");
|
||||||
|
|
||||||
|
Assert.Equal(expectedLive, LiveUpliftStatus.IsLive(storedStatus));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,11 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
private static ClaimsPrincipal Dispatcher(string userId = "dispatcher-1")
|
private static ClaimsPrincipal Dispatcher(string userId = "dispatcher-1")
|
||||||
=> WorkOrderAccountTestHelpers.OrgWideAdmin(userId);
|
=> WorkOrderAccountTestHelpers.OrgWideAdmin(userId);
|
||||||
|
|
||||||
|
// Same user as Dispatcher(), without the Admin role: the only kind of caller that
|
||||||
|
// may revoke an auto-approved uplift, and only their own.
|
||||||
|
private static ClaimsPrincipal DispatcherRoleOnly(string userId = "dispatcher-1")
|
||||||
|
=> WorkOrderAccountTestHelpers.AccountUser(userId, 1, "Dispatcher");
|
||||||
|
|
||||||
private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync(
|
private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync(
|
||||||
ApplicationDbContext context,
|
ApplicationDbContext context,
|
||||||
WorkOrderType type = WorkOrderType.PM,
|
WorkOrderType type = WorkOrderType.PM,
|
||||||
|
|
@ -585,7 +590,7 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
workOrder.Id,
|
workOrder.Id,
|
||||||
created.Id,
|
created.Id,
|
||||||
new RevokeWorkOrderUpliftRequestDto(),
|
new RevokeWorkOrderUpliftRequestDto(),
|
||||||
Dispatcher(),
|
DispatcherRoleOnly(),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// SH-196: revoking frees the allowance, so it must release the NTE too. Otherwise
|
// SH-196: revoking frees the allowance, so it must release the NTE too. Otherwise
|
||||||
|
|
@ -724,7 +729,7 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
workOrder.Id,
|
workOrder.Id,
|
||||||
created!.Id,
|
created!.Id,
|
||||||
new RevokeWorkOrderUpliftRequestDto(),
|
new RevokeWorkOrderUpliftRequestDto(),
|
||||||
Dispatcher(),
|
DispatcherRoleOnly(),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal("revoked", revoked!.Status);
|
Assert.Equal("revoked", revoked!.Status);
|
||||||
|
|
@ -733,6 +738,35 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
.SumAutoApprovedAmountForWorkOrderAsync(workOrder.Id, CancellationToken.None));
|
.SumAutoApprovedAmountForWorkOrderAsync(workOrder.Id, CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RevokeAsync_AdminOwnerRevokingAutoApproved_IsForbiddenAndKeepsAllowanceConsumed()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var (workOrder, _) = await SeedWorkOrderAsync(context);
|
||||||
|
var service = NewService(context);
|
||||||
|
|
||||||
|
var created = await service.CreateAsync(
|
||||||
|
workOrder.Id,
|
||||||
|
new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Within limit" },
|
||||||
|
Dispatcher(),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.Equal("auto_approved", created!.Status);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<UpliftForbiddenException>(() => service.RevokeAsync(
|
||||||
|
workOrder.Id,
|
||||||
|
created.Id,
|
||||||
|
new RevokeWorkOrderUpliftRequestDto { Reason = "Wrong quote" },
|
||||||
|
Dispatcher(),
|
||||||
|
CancellationToken.None));
|
||||||
|
|
||||||
|
var stored = context.DispatchUpliftRequests.Single(u => u.Id == created.Id);
|
||||||
|
Assert.Equal(UpliftStatus.NoApprovalRequired, stored.Status);
|
||||||
|
Assert.Null(stored.DecisionNote);
|
||||||
|
Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
|
||||||
|
Assert.Equal(400m, await new UpliftDataService(context)
|
||||||
|
.SumAutoApprovedAmountForWorkOrderAsync(workOrder.Id, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(LifecycleStatus.Completed)]
|
[InlineData(LifecycleStatus.Completed)]
|
||||||
[InlineData(LifecycleStatus.Canceled)]
|
[InlineData(LifecycleStatus.Canceled)]
|
||||||
|
|
@ -760,7 +794,7 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
workOrder.Id,
|
workOrder.Id,
|
||||||
100,
|
100,
|
||||||
new RevokeWorkOrderUpliftRequestDto(),
|
new RevokeWorkOrderUpliftRequestDto(),
|
||||||
Dispatcher(),
|
DispatcherRoleOnly(),
|
||||||
CancellationToken.None));
|
CancellationToken.None));
|
||||||
Assert.Contains("work order", ex.Message, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("work order", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue