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