diff --git a/Api.SeaHavenIndustries.Tests/UpliftRevokeEndpointRulesTests.cs b/Api.SeaHavenIndustries.Tests/UpliftRevokeEndpointRulesTests.cs
new file mode 100644
index 0000000..41a45df
--- /dev/null
+++ b/Api.SeaHavenIndustries.Tests/UpliftRevokeEndpointRulesTests.cs
@@ -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;
+
+///
+/// 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.
+///
+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()
+ .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 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(),
+ Mock.Of(),
+ workOrderFlow,
+ Mock.Of>())
+ {
+ 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(),
+ TimeProvider.System,
+ Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
+ workOrderFlow);
+ var approvals = new UpliftController(upliftService, Mock.Of>())
+ {
+ 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().Subject;
+ refused.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
+ refused.Value.Should().BeOfType().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().Subject;
+ refused.Value.Should().BeOfType().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();
+ 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();
+ 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();
+ 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);
+ }
+}
diff --git a/SeaHaven.DataServices/Helpers/LiveUpliftStatus.cs b/SeaHaven.DataServices/Helpers/LiveUpliftStatus.cs
new file mode 100644
index 0000000..e976eea
--- /dev/null
+++ b/SeaHaven.DataServices/Helpers/LiveUpliftStatus.cs
@@ -0,0 +1,26 @@
+namespace SeaHaven.DataServices.Helpers
+{
+ ///
+ /// 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.
+ ///
+ public static class LiveUpliftStatus
+ {
+ ///
+ /// Stored statuses that are not live. Kept as an array so EF translates
+ /// NonLive.Contains(u.Status) inside query predicates.
+ ///
+ public static readonly string[] NonLive =
+ {
+ "Withdrawn",
+ "Cancelled",
+ "Expired",
+ "Revoked",
+ };
+
+ public static bool IsLive(string? status) =>
+ status == null || !NonLive.Contains(status, StringComparer.Ordinal);
+ }
+}
diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
index 12d22ce..e14972f 100644
--- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
+++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
@@ -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));
diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs
index d3bdfea..61afea5 100644
--- a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs
+++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs
@@ -15,12 +15,6 @@ namespace SeaHaven.DataServices.Helpers
WorkOrderType.Reactive,
};
- private static readonly string[] RevokedUpliftStatuses =
- {
- "Withdrawn",
- "Cancelled",
- };
-
public static IQueryable ApplySeverityFilter(
IQueryable query,
IReadOnlyList? 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 MapUpliftStatuses(IReadOnlyList? 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;
}
}
diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs
index 0980e0d..87a1834 100644
--- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs
@@ -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))
{
diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs
index adba1a7..3708240 100644
--- a/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs
@@ -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);
diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusRelationalTests.cs
new file mode 100644
index 0000000..d1f3032
--- /dev/null
+++ b/SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusRelationalTests.cs
@@ -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()
+ .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? 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 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;
+ }
+ }
+ }
+}
diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusTests.cs
new file mode 100644
index 0000000..0370751
--- /dev/null
+++ b/SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusTests.cs
@@ -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;
+
+///
+/// "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.
+///
+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()
+ .UseInMemoryDatabase(Guid.NewGuid().ToString())
+ .Options;
+ return new ApplicationDbContext(options);
+ }
+
+ private static async Task 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 SearchIdsAsync(
+ ApplicationDbContext context,
+ bool hasUplift,
+ List? 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 { "pending" }));
+ }
+
+ [Fact]
+ public async Task HasUplift_RejectedUpliftStillCounts()
+ {
+ await using var context = await SeedAsync();
+
+ Assert.Equal(
+ new[] { RejectedOnly },
+ await SearchIdsAsync(context, hasUplift: true, new List { "rejected" }));
+ }
+
+ [Fact]
+ public async Task HasUplift_ExplicitCancelledStatus_FindsCancelledWithdrawnAndExpiredUplifts()
+ {
+ await using var context = await SeedAsync();
+
+ var ids = await SearchIdsAsync(context, hasUplift: true, new List { "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 { "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));
+ }
+}
diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs
index 390beaf..9685f70 100644
--- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs
@@ -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(() => 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);
}