Merge remote-tracking branch 'origin/main' into fix/ab/sh-403-reset-hardening-2

This commit is contained in:
Alexandre Brandizzi 2026-09-25 19:27:01 -03:00
commit 0298fc75bd
9 changed files with 789 additions and 13 deletions

View 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);
}
}

View 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);
}
}

View file

@ -238,11 +238,13 @@ namespace SeaHaven.DataServices.Helpers
CancellationToken cancellationToken)
{
var workOrderIds = workOrders.Select(w => w.Id);
var nonLive = LiveUpliftStatus.NonLive;
var live = context.DispatchUpliftRequests
.AsNoTracking()
.Where(u =>
(u.IsDeleted == null || u.IsDeleted == false)
&& !nonLive.Contains(u.Status)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false));

View file

@ -15,12 +15,6 @@ namespace SeaHaven.DataServices.Helpers
WorkOrderType.Reactive,
};
private static readonly string[] RevokedUpliftStatuses =
{
"Withdrawn",
"Cancelled",
};
public static IQueryable<WorkOrder> ApplySeverityFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<int>? severities)
@ -125,18 +119,21 @@ namespace SeaHaven.DataServices.Helpers
if (!hasUplift)
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 anyLive = mappedStatuses.Count == 0;
var nonLive = LiveUpliftStatus.NonLive;
return query.Where(w => context.DispatchUpliftRequests.Any(u =>
(u.IsDeleted == null || u.IsDeleted == false)
&& u.Status != "Expired"
&& !RevokedUpliftStatuses.Contains(u.Status)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
&& (
u.Dispatch.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)
@ -166,6 +163,14 @@ namespace SeaHaven.DataServices.Helpers
mapped.Add("Rejected");
mapped.Add("Denied");
break;
case "cancelled":
mapped.Add("Withdrawn");
mapped.Add("Cancelled");
mapped.Add("Expired");
break;
case "revoked":
mapped.Add("Revoked");
break;
}
}

View file

@ -273,6 +273,11 @@ namespace SeaHaven.Services.Implementation
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)
|| !string.Equals(req.createdby, userId, StringComparison.Ordinal))
{

View file

@ -212,7 +212,7 @@ public sealed class WorkOrderUpliftDispatchOwnershipTests
row.Id,
created!.Id,
new RevokeWorkOrderUpliftRequestDto(),
WorkOrderAccountTestHelpers.AccountUser(),
WorkOrderAccountTestHelpers.AccountUser("actor-1", 1, "Dispatcher"),
CancellationToken.None);
Assert.Equal("revoked", revoked!.Status);

View file

@ -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;
}
}
}
}

View 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));
}
}

View file

@ -48,6 +48,11 @@ public sealed class WorkOrderUpliftServiceTests
private static ClaimsPrincipal Dispatcher(string userId = "dispatcher-1")
=> 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(
ApplicationDbContext context,
WorkOrderType type = WorkOrderType.PM,
@ -585,7 +590,7 @@ public sealed class WorkOrderUpliftServiceTests
workOrder.Id,
created.Id,
new RevokeWorkOrderUpliftRequestDto(),
Dispatcher(),
DispatcherRoleOnly(),
CancellationToken.None);
// SH-196: revoking frees the allowance, so it must release the NTE too. Otherwise
@ -724,7 +729,7 @@ public sealed class WorkOrderUpliftServiceTests
workOrder.Id,
created!.Id,
new RevokeWorkOrderUpliftRequestDto(),
Dispatcher(),
DispatcherRoleOnly(),
CancellationToken.None);
Assert.Equal("revoked", revoked!.Status);
@ -733,6 +738,35 @@ public sealed class WorkOrderUpliftServiceTests
.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]
[InlineData(LifecycleStatus.Completed)]
[InlineData(LifecycleStatus.Canceled)]
@ -760,7 +794,7 @@ public sealed class WorkOrderUpliftServiceTests
workOrder.Id,
100,
new RevokeWorkOrderUpliftRequestDto(),
Dispatcher(),
DispatcherRoleOnly(),
CancellationToken.None));
Assert.Contains("work order", ex.Message, StringComparison.OrdinalIgnoreCase);
}