using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Implementation; using SeaHaven.DataServices.Interfaces; 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(), new PassThroughUpliftData()); 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), new TeamPermissionOverrideDataService(context), new TeamPermissionPolicy(), TimeProvider.System, Options.Create(new ApprovalsOptions())); var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts, new PassThroughUpliftData()); 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"); } [Fact] public async Task Cancel_LateSaveFailure_LeavesPendingUpliftAndOpenWorkOrder() { var databaseName = Guid.NewGuid().ToString(); var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName) .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), new TeamPermissionOverrideDataService(context), new TeamPermissionPolicy(), TimeProvider.System, Options.Create(new ApprovalsOptions())); var cancel = new WorkOrderBoardCancelService( new ThrowingSaveMutationData(mutationData), boardService, audit, uplifts, new PassThroughUpliftData()); await Assert.ThrowsAsync(() => cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1")); await using var verify = new ApplicationDbContext(options); Assert.Equal(LifecycleStatus.Incomplete, Assert.Single(verify.workOrders).LifecycleStatus); Assert.Equal("Pending", Assert.Single(verify.DispatchUpliftRequests).Status); Assert.Empty(verify.WorkOrderAuditLogs); } [Fact] public async Task Cancel_SerializedWithCreate_DoesNotLeavePendingOnCanceledWorkOrder() { var databaseName = Guid.NewGuid().ToString(); var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName) .Options; await using (var seed = new ApplicationDbContext(options)) { await WorkOrderAccountTestHelpers.EnsureAccountAsync(seed); seed.Users.Add(new ApplicationUser { Id = "dispatcher-1", UserName = "dispatcher-1", FirstName = "Alex", LastName = "Dispatcher", }); var adminRole = new IdentityRole("Admin"); seed.Roles.Add(adminRole); seed.UserRoles.Add(new IdentityUserRole { UserId = "dispatcher-1", RoleId = adminRole.Id, }); seed.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); seed.Dispatches.Add(new Dispatch { Id = 10, VendorId = 1, WorkOrderId = 1, NTEAmount = 1000m, DispatchNumber = "DIS-10", Status = "Scheduled", }); seed.workOrders.Add(new WorkOrder { Id = 1, InternalWONumber = "10000000001", PrimaryDispatchId = 10, AccountId = 1, WorkOrderType = WorkOrderType.PM, LifecycleStatus = LifecycleStatus.Incomplete, Status = "Incomplete", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }, }); await seed.SaveChangesAsync(); } await using var createContext = new ApplicationDbContext(options); await using var cancelContext = new ApplicationDbContext(options); var createService = new WorkOrderUpliftService( new UpliftDataService(createContext), new DispatchDataService(createContext), new WorkOrderDetailDataService(createContext), WorkOrderAccountTestHelpers.Resolver(createContext), new UserDataService(createContext), new TeamPermissionOverrideDataService(createContext), new TeamPermissionPolicy(), TimeProvider.System, Options.Create(new ApprovalsOptions())); var boardData = new WorkOrderBoardDataService(cancelContext); var mutationData = new WorkOrderBoardMutationDataService(cancelContext); var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(cancelContext)); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(cancelContext)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(cancelContext), fieldLocks); var cancelUplifts = new WorkOrderUpliftService( new UpliftDataService(cancelContext), new DispatchDataService(cancelContext), new WorkOrderDetailDataService(cancelContext), WorkOrderAccountTestHelpers.Resolver(cancelContext), new UserDataService(cancelContext), new TeamPermissionOverrideDataService(cancelContext), new TeamPermissionPolicy(), TimeProvider.System, Options.Create(new ApprovalsOptions())); var cancel = new WorkOrderBoardCancelService( mutationData, boardService, audit, cancelUplifts, new UpliftDataService(cancelContext)); await Task.WhenAll( createService.CreateAsync( 1, new CreateWorkOrderUpliftRequestDto { Amount = 501m, Notes = "Pending" }, WorkOrderAccountTestHelpers.OrgWideAdmin("dispatcher-1"), CancellationToken.None), cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1")); await using var verify = new ApplicationDbContext(options); var workOrder = Assert.Single(verify.workOrders); Assert.Equal(LifecycleStatus.Canceled, workOrder.LifecycleStatus); Assert.DoesNotContain(verify.DispatchUpliftRequests, row => row.Status == "Pending"); } private sealed class ThrowingSaveMutationData : IWorkOrderBoardMutationDataService { private readonly IWorkOrderBoardMutationDataService _inner; public ThrowingSaveMutationData(IWorkOrderBoardMutationDataService inner) { _inner = inner; } public Task VendorExistsAsync(int vendorId, CancellationToken cancellationToken) => _inner.VendorExistsAsync(vendorId, cancellationToken); public Task GetMaxWorkOrderIdAsync(CancellationToken cancellationToken) => _inner.GetMaxWorkOrderIdAsync(cancellationToken); public Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => _inner.GetTrackedWorkOrderAsync(workOrderId, cancellationToken); public Task GetTrackedDispatchAsync(int dispatchId, int workOrderId, CancellationToken cancellationToken) => _inner.GetTrackedDispatchAsync(dispatchId, workOrderId, cancellationToken); public Task GetTrackedDispatchByIdAsync(int dispatchId, CancellationToken cancellationToken) => _inner.GetTrackedDispatchByIdAsync(dispatchId, cancellationToken); public void DetachWorkOrderFromDispatch(Dispatch dispatch, int workOrderId) => _inner.DetachWorkOrderFromDispatch(dispatch, workOrderId); public void TrackNewWorkOrder(WorkOrder workOrder) => _inner.TrackNewWorkOrder(workOrder); public void TrackNewDispatch(Dispatch dispatch) => _inner.TrackNewDispatch(dispatch); public void TrackWorkOrderContact(int workOrderId, int contactId, string? notes) => _inner.TrackWorkOrderContact(workOrderId, contactId, notes); public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version) => _inner.SetExpectedWorkOrderVersion(workOrder, version); public void SetExpectedDispatchVersion(Dispatch dispatch, byte[] version) => _inner.SetExpectedDispatchVersion(dispatch, version); public bool HasPendingChanges() => _inner.HasPendingChanges(); public Task ExecuteTransactionalAsync(Func work, CancellationToken cancellationToken) => _inner.ExecuteTransactionalAsync(work, cancellationToken); public Task SaveAsync(CancellationToken cancellationToken) => throw new InvalidOperationException("forced late failure"); } /// /// SH-196: the cancel flow now runs inside the per-work-order gate. Tests only need the /// gate to invoke the work; the real serialization is exercised against SQL Server. /// private sealed class PassThroughUpliftData : IUpliftDataService { public Task ExecuteWorkOrderMutationAsync( int workOrderId, Func> work, CancellationToken cancellationToken) => work(cancellationToken); public Task<(int TotalCount, IReadOnlyList Items)> GetPagedAsync( string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetByIdAsync(int id, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetWorkOrderIdForUpliftAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetUpliftDispatchForWorkOrderAsync(int workOrderId, int? primaryDispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetPendingExposureTotalAsync(CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task HasActiveAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetByRequestKeyAsync(int dispatchId, string requestKey, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetDueForExpiryAsync(DateTime utcNow, int count, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetDueForInitialNotificationAsync(int count, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetDueForEscalationAsync(int count, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task SaveChangesAsync(CancellationToken cancellationToken) => throw new NotSupportedException(); } 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; } }