using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using SeaHaven.DataServices.Implementation; using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using Xunit; namespace SeaHavenIndustries.Tests; /// /// Cancelling a work order from the board (the lifecycle status PATCH the board and /// slide-over use) cancels its pending uplift in the same action and commit, with an /// audit entry of its own. /// public sealed class WorkOrderBoardPatchCancelUpliftTests { private static readonly byte[] Version = { 1, 0, 0, 0, 0, 0, 0, 1 }; private static async Task CancelPatchAsync(ApplicationDbContext context) { var stored = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == 1); return new WorkOrderBoardPatchRequestDto { Field = WorkOrderBoardFieldNames.LifecycleStatus, Value = "Canceled", WorkOrderVersion = Convert.ToBase64String(stored.RowVersion ?? Version), }; } private static WorkOrderBoardUpdateService NewUpdateService(ApplicationDbContext context) { var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); return WorkOrderAccountTestHelpers.BoardUpdateService(context, audit); } // Saved in stages: the work order and its primary dispatch reference each other, which a // relational provider cannot insert in one statement batch. internal static async Task SeedWorkOrderAsync(ApplicationDbContext context) { context.Accounts.Add(new Accounts { Id = 1, Name = "Acme Corp", IsDeleted = false }); context.Users.Add(new ApplicationUser { Id = "actor-1", UserName = "actor-1" }); context.Users.Add(new ApplicationUser { Id = "dispatcher-1", UserName = "dispatcher-1" }); context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); var workOrder = new WorkOrder { Id = 1, InternalWONumber = "10000000001", ExternalWorkOrderId = "123", LifecycleStatus = LifecycleStatus.Incomplete, Status = "Incomplete", AccountId = 1, RowVersion = Version, }; context.workOrders.Add(workOrder); await context.SaveChangesAsync(); context.Dispatches.Add(new Dispatch { Id = 10, VendorId = 1, WorkOrderId = 1, NTEAmount = 1900m, DispatchNumber = "DIS-10", Status = "Scheduled", }); await context.SaveChangesAsync(); workOrder.PrimaryDispatchId = 10; await context.SaveChangesAsync(); } internal static DispatchUpliftRequest Uplift(int id, string status, decimal amount) => new() { Id = id, DispatchId = 10, CurrentNTE = 1000m, RequestedNTE = amount, Status = status, RequiredTier = status == "NoApprovalRequired" ? 0 : 1, NotificationStatus = "Sent", createdby = "dispatcher-1", CreatedDate = DateTime.UtcNow.AddHours(-id), }; [Fact] public async Task PatchToCanceled_CancelsPendingUpliftWithItsOwnAuditAndLeavesDecidedUpliftsAlone() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; await using (var seed = new ApplicationDbContext(options)) { await SeedWorkOrderAsync(seed); seed.DispatchUpliftRequests.AddRange( Uplift(100, "Pending", 700m), Uplift(101, "Approved", 600m), Uplift(102, "NoApprovalRequired", 300m), Uplift(103, "Rejected", 900m)); await seed.SaveChangesAsync(); } await using (var context = new ApplicationDbContext(options)) { var row = await NewUpdateService(context).PatchFieldAsync(1, await CancelPatchAsync(context), "actor-1"); Assert.Equal(LifecycleStatus.Canceled, row.LifecycleStatus); } await using var verify = new ApplicationDbContext(options); Assert.Equal(LifecycleStatus.Canceled, verify.workOrders.Single().LifecycleStatus); var cancelled = verify.DispatchUpliftRequests.Single(u => u.Id == 100); Assert.Equal(UpliftStatus.Withdrawn, cancelled.Status); Assert.Equal("actor-1", cancelled.DecidedByUserId); Assert.NotNull(cancelled.DecidedAt); Assert.Equal(UpliftStatus.Approved, verify.DispatchUpliftRequests.Single(u => u.Id == 101).Status); Assert.Equal(UpliftStatus.NoApprovalRequired, verify.DispatchUpliftRequests.Single(u => u.Id == 102).Status); Assert.Equal(UpliftStatus.Rejected, verify.DispatchUpliftRequests.Single(u => u.Id == 103).Status); Assert.Equal(1900m, verify.Dispatches.Single().NTEAmount); var upliftAudit = Assert.Single(verify.WorkOrderAuditLogs, log => log.Action == "uplift_cancel"); Assert.Equal(1, upliftAudit.WorkOrderId); Assert.Equal("actor-1", upliftAudit.UserId); Assert.Equal("Dispatch DIS-10 Uplift", upliftAudit.FieldName); Assert.Equal(UpliftStatus.Pending, upliftAudit.OldValue); Assert.Equal(UpliftStatus.Withdrawn, upliftAudit.NewValue); var statusAudit = Assert.Single(verify.WorkOrderAuditLogs, log => log.Action == "StatusChanged"); Assert.Equal(LifecycleStatus.Canceled.ToString(), statusAudit.NewValue); Assert.NotEqual(upliftAudit.Id, statusAudit.Id); } [Fact] public async Task PatchToOtherStatus_LeavesPendingUpliftPending() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; await using (var seed = new ApplicationDbContext(options)) { await SeedWorkOrderAsync(seed); seed.DispatchUpliftRequests.Add(Uplift(100, "Pending", 700m)); await seed.SaveChangesAsync(); } await using (var context = new ApplicationDbContext(options)) { var patch = await CancelPatchAsync(context); patch.Value = "Pending"; await NewUpdateService(context).PatchFieldAsync(1, patch, "actor-1"); } await using var verify = new ApplicationDbContext(options); Assert.Equal(LifecycleStatus.Pending, verify.workOrders.Single().LifecycleStatus); Assert.Equal(UpliftStatus.Pending, verify.DispatchUpliftRequests.Single().Status); Assert.DoesNotContain(verify.WorkOrderAuditLogs, log => log.Action == "uplift_cancel"); } [Fact] public async Task PatchToCanceled_FailureAfterTheWrites_RollsBackWorkOrderAndUpliftTogether() { await using var connection = new SqliteConnection("DataSource=:memory:"); await connection.OpenAsync(); var failAfterSave = new FailAfterSaveInterceptor(); var options = new DbContextOptionsBuilder() .UseSqlite(connection) .AddInterceptors(failAfterSave) .Options; await using (var seed = new SqliteRowVersionDbContext(options)) { await seed.Database.EnsureCreatedAsync(); await SeedWorkOrderAsync(seed); seed.DispatchUpliftRequests.Add(Uplift(100, "Pending", 700m)); await seed.SaveChangesAsync(); } failAfterSave.Armed = true; await using (var context = new SqliteRowVersionDbContext(options)) { var patch = await CancelPatchAsync(context); await Assert.ThrowsAsync( () => NewUpdateService(context).PatchFieldAsync(1, patch, "actor-1")); } // The failing save did write both the cancelled work order and the withdrawn // uplift inside the transaction; the failure after it must undo both. Assert.True(failAfterSave.SawWithdrawnUpliftInSave); failAfterSave.Armed = false; await using var verify = new SqliteRowVersionDbContext(options); Assert.Equal(LifecycleStatus.Incomplete, (await verify.workOrders.AsNoTracking().SingleAsync()).LifecycleStatus); Assert.Equal(UpliftStatus.Pending, (await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync()).Status); Assert.False(await verify.WorkOrderAuditLogs.AnyAsync()); } private sealed class FailAfterSaveInterceptor : SaveChangesInterceptor { public bool Armed { get; set; } public bool SawWithdrawnUpliftInSave { get; private set; } public override ValueTask> SavingChangesAsync( DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { if (Armed && eventData.Context is not null) { SawWithdrawnUpliftInSave = eventData.Context.ChangeTracker .Entries() .Any(entry => entry.State == EntityState.Modified && entry.Entity.Status == UpliftStatus.Withdrawn); } return base.SavingChangesAsync(eventData, result, cancellationToken); } public override ValueTask SavedChangesAsync( SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default) { if (Armed) throw new InvalidOperationException("Simulated failure after the rows were written"); return base.SavedChangesAsync(eventData, result, cancellationToken); } } internal sealed class SqliteRowVersionDbContext : ApplicationDbContext { public SqliteRowVersionDbContext(DbContextOptions options) : base(options) { } protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); // SQLite has no rowversion type; store the seeded blobs as plain values. foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch), typeof(DispatchUpliftRequest) }) { var property = builder.Entity(entityType).Property("RowVersion").Metadata; property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never; property.IsConcurrencyToken = false; } } } }