using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Implementation; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; namespace SeaHavenIndustries.Tests; public class WorkOrderBoardCancelServiceTests { private static (ApplicationDbContext Context, WorkOrderBoardCancelService Cancel, WorkOrderBoardUpdateService Update) CreateSut() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; var context = new ApplicationDbContext(options); var boardData = new WorkOrderBoardDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context); var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context)); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, new NoOpUpliftService()); var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit); return (context, cancel, update); } private static string ToVersion(WorkOrder workOrder) => Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); [Fact] public async Task Cancel_SetsLifecycleStatusCanceled() { var (context, cancel, _) = CreateSut(); var wo = new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Incomplete, Status = "Incomplete", SiteCode = "BK5", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }; context.workOrders.Add(wo); await context.SaveChangesAsync(); var result = await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"); Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus); Assert.Equal("Canceled", result.LifecycleStatusLabel); } [Fact] public async Task Cancel_IsIdempotentWhenAlreadyCanceled() { var (context, cancel, _) = CreateSut(); var wo = new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Canceled, Status = "Canceled", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }; context.workOrders.Add(wo); await context.SaveChangesAsync(); var before = await context.WorkOrderAuditLogs.CountAsync(); var result = await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"); var after = await context.WorkOrderAuditLogs.CountAsync(); Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus); Assert.Equal(before, after); } [Fact] public async Task Cancel_BlocksWhenCompleted() { var (context, cancel, _) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Completed, Status = "Completed" }); await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1")); Assert.Equal("CancelNotAllowed", ex.Code); } [Fact] public async Task Cancel_BlocksWhenClosedLabel() { var (context, cancel, _) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Completed, Status = "Closed" }); await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1")); Assert.Equal("CancelNotAllowed", ex.Code); } [Fact] public async Task Cancel_WritesStatusChangedAudit() { var (context, cancel, _) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled, Status = "Scheduled", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); await context.SaveChangesAsync(); await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"); var log = await context.WorkOrderAuditLogs.SingleAsync(l => l.Action == "StatusChanged"); Assert.Equal("Canceled", log.NewValue); } [Fact] public async Task Patch_AfterCancel_IsBlocked() { var (context, cancel, update) = CreateSut(); var wo = new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Incomplete, Status = "Incomplete", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }; context.workOrders.Add(wo); await context.SaveChangesAsync(); await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"); var ex = await Assert.ThrowsAsync(() => update.PatchFieldAsync(1, new SeaHaven.Services.DTOs.WorkOrderBoardPatchRequestDto { Field = WorkOrderBoardFieldNames.SiteCode, Value = "NEW", WorkOrderVersion = ToVersion(wo) }, "actor-1")); Assert.Equal("CanceledReadOnly", ex.Code); } [Fact] public async Task Cancel_WithdrawsPendingUpliftAndWritesAudit() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; await using var context = new ApplicationDbContext(options); await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); context.Dispatches.Add(new Dispatch { Id = 10, VendorId = 1, WorkOrderId = 1, DispatchNumber = "DIS-10", Status = "Scheduled", }); context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Incomplete, Status = "Incomplete", PrimaryDispatchId = 10, AccountId = 1, RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { Id = 100, DispatchId = 10, RequestedNTE = 600m, Status = "Pending", RequiredTier = 1, NotificationStatus = "Pending", }); await context.SaveChangesAsync(); var boardData = new WorkOrderBoardDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context); var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context)); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var uplifts = new WorkOrderUpliftService( new UpliftDataService(context), new DispatchDataService(context), new WorkOrderDetailDataService(context), WorkOrderAccountTestHelpers.Resolver(context), new UserDataService(context), TimeProvider.System, Options.Create(new ApprovalsOptions())); var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts); var result = await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"); Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus); Assert.Equal("Withdrawn", Assert.Single(context.DispatchUpliftRequests).Status); Assert.Contains(context.WorkOrderAuditLogs, log => log.Action == "uplift_cancel"); Assert.Contains(context.WorkOrderAuditLogs, log => log.Action == "StatusChanged"); } private sealed class NoOpUpliftService : IWorkOrderUpliftService { public Task?> ListAsync( int workOrderId, System.Security.Claims.ClaimsPrincipal user, CancellationToken cancellationToken) => Task.FromResult?>(Array.Empty()); public Task CreateAsync( int workOrderId, CreateWorkOrderUpliftRequestDto request, System.Security.Claims.ClaimsPrincipal user, CancellationToken cancellationToken) => Task.FromResult(null); public Task CancelAsync( int workOrderId, int upliftId, System.Security.Claims.ClaimsPrincipal user, CancellationToken cancellationToken) => Task.FromResult(null); public Task RevokeAsync( int workOrderId, int upliftId, RevokeWorkOrderUpliftRequestDto request, System.Security.Claims.ClaimsPrincipal user, CancellationToken cancellationToken) => Task.FromResult(null); public Task WithdrawPendingForWorkOrderAsync( int workOrderId, string? actorId, CancellationToken cancellationToken) => Task.CompletedTask; } }