using System.Data.Common; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using SeaHaven.DataServices.Implementation; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using Xunit; namespace SeaHavenIndustries.Tests; /// /// A work order cancelled by the CRM (webhook or reconciliation, both persisted by the /// webhook data service, or the legacy ingest) cancels its pending uplift in the same save, /// with an audit entry of its own, exactly as a cancel from the board does. /// public sealed class WorkOrderCrmCancelUpliftTests { private static readonly DateTimeOffset ProcessedAt = new(2026, 7, 24, 12, 1, 0, TimeSpan.Zero); private static WorkOrderWebhookMutation CrmMutation(string eventType, bool cancelled) => new() { DeliveryId = $"delivery-{eventType}", EventType = eventType, OccurredAt = ProcessedAt.AddMinutes(-1), UpdatedAt = ProcessedAt.AddMinutes(-1), ProcessedAt = ProcessedAt, BodySha256 = $"hash-{eventType}", VersionHash = $"hash-{eventType}", ExternalWorkOrderId = "123", WorkerOrderNumber = "00000000123", Source = "procurement", IsStateEvent = true, Customer = "Acme Corp", Status = "Open", IsCancelled = cancelled, LifecycleStatus = cancelled ? LifecycleStatus.Canceled : LifecycleStatus.Pending, }; private static async Task> SeedInMemoryAsync( params DispatchUpliftRequest[] uplifts) { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; await using var seed = new ApplicationDbContext(options); await WorkOrderBoardPatchCancelUpliftTests.SeedWorkOrderAsync(seed); seed.DispatchUpliftRequests.AddRange(uplifts); await seed.SaveChangesAsync(); return options; } [Fact] public async Task CrmCancel_CancelsPendingUpliftWithItsOwnSyncAuditAndLeavesDecidedUpliftsAlone() { var options = await SeedInMemoryAsync( WorkOrderBoardPatchCancelUpliftTests.Uplift(100, "Pending", 700m), WorkOrderBoardPatchCancelUpliftTests.Uplift(101, "Approved", 600m), WorkOrderBoardPatchCancelUpliftTests.Uplift(102, "NoApprovalRequired", 300m), WorkOrderBoardPatchCancelUpliftTests.Uplift(103, "Rejected", 900m)); await using (var context = new ApplicationDbContext(options)) { var result = await new WorkOrderWebhookDataService(context) .ApplyAsync(CrmMutation("work_order.cancelled", cancelled: true), CancellationToken.None); Assert.Equal(WorkOrderWebhookPersistenceStatus.Applied, result.Status); } 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(ProcessedAt.UtcDateTime, cancelled.DecidedAt); Assert.Null(cancelled.DecidedByUserId); 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); Assert.Equal("uplift_cancel", upliftAudit.Action); Assert.Equal(1, upliftAudit.WorkOrderId); Assert.Equal(AuditActorType.Sync.ToString(), upliftAudit.ActorType); Assert.Null(upliftAudit.UserId); Assert.Equal("Dispatch DIS-10 Uplift", upliftAudit.FieldName); Assert.Equal(UpliftStatus.Pending, upliftAudit.OldValue); Assert.Equal(UpliftStatus.Withdrawn, upliftAudit.NewValue); } [Fact] public async Task CrmCancel_WaitsForAnUpliftCreateInFlightAndCancelsWhatItCommitted() { var options = await SeedInMemoryAsync(); var createEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var releaseCreate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); // An uplift create holding the work order's lock, committing its Pending request // only after the CRM cancel has started. await using var createContext = new ApplicationDbContext(options); var create = new UpliftDataService(createContext).ExecuteWorkOrderMutationAsync(1, async ct => { createEntered.SetResult(); await releaseCreate.Task; createContext.DispatchUpliftRequests.Add(WorkOrderBoardPatchCancelUpliftTests.Uplift(200, "Pending", 800m)); await createContext.SaveChangesAsync(ct); return true; }, CancellationToken.None); await createEntered.Task; await using var crmContext = new ApplicationDbContext(options); var cancel = new WorkOrderWebhookDataService(crmContext) .ApplyAsync(CrmMutation("work_order.cancelled", cancelled: true), CancellationToken.None); var finishedFirst = await Task.WhenAny(cancel, Task.Delay(TimeSpan.FromSeconds(2))); Assert.NotSame(cancel, finishedFirst); releaseCreate.SetResult(); await create; await cancel; await using var verify = new ApplicationDbContext(options); Assert.Equal(LifecycleStatus.Canceled, verify.workOrders.Single().LifecycleStatus); Assert.Equal(UpliftStatus.Withdrawn, verify.DispatchUpliftRequests.Single(u => u.Id == 200).Status); Assert.Single(verify.WorkOrderAuditLogs, log => log.Action == "uplift_cancel"); } [Fact] public async Task CrmUpdateThatDoesNotCancel_LeavesPendingUpliftPending() { var options = await SeedInMemoryAsync( WorkOrderBoardPatchCancelUpliftTests.Uplift(100, "Pending", 700m)); await using (var context = new ApplicationDbContext(options)) { await new WorkOrderWebhookDataService(context) .ApplyAsync(CrmMutation("work_order.updated", cancelled: false), CancellationToken.None); } await using var verify = new ApplicationDbContext(options); Assert.Equal(LifecycleStatus.Pending, verify.workOrders.Single().LifecycleStatus); Assert.Equal(UpliftStatus.Pending, verify.DispatchUpliftRequests.Single().Status); Assert.Empty(verify.WorkOrderAuditLogs); } [Fact] public async Task ReconciliationCancel_CancelsPendingUplift() { var options = await SeedInMemoryAsync( WorkOrderBoardPatchCancelUpliftTests.Uplift(100, "Pending", 700m)); await using (var context = new ApplicationDbContext(options)) { var service = WorkOrderReconciliationTests.CreateService( new[] { new ProcurementWorkOrder { WorkOrderId = "123", WoStatus = "cancelled", Customer = "Acme Corp", UpdatedAt = ProcessedAt, } }, new WorkOrderWebhookDataService(context)); Assert.True(await service.RunPendingAsync(CancellationToken.None)); } await using var verify = new ApplicationDbContext(options); Assert.Equal(LifecycleStatus.Canceled, verify.workOrders.Single().LifecycleStatus); Assert.Equal(UpliftStatus.Withdrawn, verify.DispatchUpliftRequests.Single().Status); var upliftAudit = Assert.Single(verify.WorkOrderAuditLogs, log => log.Action == "uplift_cancel"); Assert.Equal(AuditActorType.Sync.ToString(), upliftAudit.ActorType); } [Fact] public async Task LegacyIngestCancel_CancelsPendingUpliftWithItsOwnSyncAuditAndLeavesDecidedUpliftsAlone() { var options = await SeedInMemoryAsync( WorkOrderBoardPatchCancelUpliftTests.Uplift(100, "Pending", 700m), WorkOrderBoardPatchCancelUpliftTests.Uplift(101, "Approved", 600m)); await using (var context = new ApplicationDbContext(options)) { await NewIngestService(context).UpsertBatchAsync(new[] { new WorkOrderIngestPayloadDto { ExternalWorkOrderId = "123", WoStatus = "cancelled" } }); } await using var verify = new ApplicationDbContext(options); Assert.Equal("Cancelled", verify.workOrders.Single().Status); Assert.Equal(UpliftStatus.Withdrawn, verify.DispatchUpliftRequests.Single(u => u.Id == 100).Status); Assert.Equal(UpliftStatus.Approved, verify.DispatchUpliftRequests.Single(u => u.Id == 101).Status); var upliftAudit = Assert.Single(verify.WorkOrderAuditLogs, log => log.Action == "uplift_cancel"); Assert.Equal(AuditActorType.Sync.ToString(), upliftAudit.ActorType); Assert.Equal("Dispatch DIS-10 Uplift", upliftAudit.FieldName); } [Fact] public async Task LegacyIngestUpdateThatDoesNotCancel_LeavesPendingUpliftPending() { var options = await SeedInMemoryAsync( WorkOrderBoardPatchCancelUpliftTests.Uplift(100, "Pending", 700m)); await using (var context = new ApplicationDbContext(options)) { await NewIngestService(context).UpsertBatchAsync(new[] { new WorkOrderIngestPayloadDto { ExternalWorkOrderId = "123", WoStatus = "in_progress" } }); } await using var verify = new ApplicationDbContext(options); Assert.Equal(UpliftStatus.Pending, verify.DispatchUpliftRequests.Single().Status); Assert.DoesNotContain(verify.WorkOrderAuditLogs, log => log.Action == "uplift_cancel"); } [Fact] public async Task LegacyIngestCancel_WaitsForAnUpliftCreateInFlightAndCancelsWhatItCommitted() { var options = await SeedInMemoryAsync(); var createEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var releaseCreate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await using var createContext = new ApplicationDbContext(options); var create = new UpliftDataService(createContext).ExecuteWorkOrderMutationAsync(1, async ct => { createEntered.SetResult(); await releaseCreate.Task; createContext.DispatchUpliftRequests.Add(WorkOrderBoardPatchCancelUpliftTests.Uplift(200, "Pending", 800m)); await createContext.SaveChangesAsync(ct); return true; }, CancellationToken.None); await createEntered.Task; await using var ingestContext = new ApplicationDbContext(options); var ingest = NewIngestService(ingestContext).UpsertBatchAsync(new[] { new WorkOrderIngestPayloadDto { ExternalWorkOrderId = "123", WoStatus = "cancelled" } }); var finishedFirst = await Task.WhenAny(ingest, Task.Delay(TimeSpan.FromSeconds(2))); Assert.NotSame(ingest, finishedFirst); releaseCreate.SetResult(); await create; await ingest; await using var verify = new ApplicationDbContext(options); Assert.Equal("Cancelled", verify.workOrders.Single().Status); Assert.Equal(UpliftStatus.Withdrawn, verify.DispatchUpliftRequests.Single(u => u.Id == 200).Status); } [Fact] public async Task LegacyIngestCancel_OnARelationalStore_CancelsPendingUpliftInTheBatchTransaction() { await using var connection = new SqliteConnection("DataSource=:memory:"); await connection.OpenAsync(); var options = new DbContextOptionsBuilder() .UseSqlite(connection) .Options; await using (var seed = new WorkOrderBoardPatchCancelUpliftTests.SqliteRowVersionDbContext(options)) { await seed.Database.EnsureCreatedAsync(); await WorkOrderBoardPatchCancelUpliftTests.SeedWorkOrderAsync(seed); seed.DispatchUpliftRequests.Add(WorkOrderBoardPatchCancelUpliftTests.Uplift(100, "Pending", 700m)); await seed.SaveChangesAsync(); } await using (var context = new WorkOrderBoardPatchCancelUpliftTests.SqliteRowVersionDbContext(options)) { await NewIngestService(context).UpsertBatchAsync(new[] { new WorkOrderIngestPayloadDto { ExternalWorkOrderId = "123", WoStatus = "cancelled" } }); } await using var verify = new WorkOrderBoardPatchCancelUpliftTests.SqliteRowVersionDbContext(options); Assert.Equal("Cancelled", (await verify.workOrders.AsNoTracking().SingleAsync()).Status); Assert.Equal(UpliftStatus.Withdrawn, (await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync()).Status); Assert.Single(await verify.WorkOrderAuditLogs.Where(log => log.Action == "uplift_cancel").ToListAsync()); } private static WorkOrderIngestService NewIngestService(ApplicationDbContext context) { var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); return new WorkOrderIngestService( new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context)); } [Fact] public async Task CrmCancel_FailureBeforeCommit_RollsBackWorkOrderAndUpliftTogether() { await using var connection = new SqliteConnection("DataSource=:memory:"); await connection.OpenAsync(); var writes = new UpdateRecorder(); var failCommit = new FailCommitInterceptor(); var options = new DbContextOptionsBuilder() .UseSqlite(connection) .AddInterceptors(writes, failCommit) .Options; await using (var seed = new WorkOrderBoardPatchCancelUpliftTests.SqliteRowVersionDbContext(options)) { await seed.Database.EnsureCreatedAsync(); await WorkOrderBoardPatchCancelUpliftTests.SeedWorkOrderAsync(seed); seed.DispatchUpliftRequests.Add(WorkOrderBoardPatchCancelUpliftTests.Uplift(100, "Pending", 700m)); await seed.SaveChangesAsync(); } writes.Armed = true; failCommit.Armed = true; await using (var context = new WorkOrderBoardPatchCancelUpliftTests.SqliteRowVersionDbContext(options)) { await Assert.ThrowsAnyAsync(() => new WorkOrderWebhookDataService(context) .ApplyAsync(CrmMutation("work_order.cancelled", cancelled: true), CancellationToken.None)); } // The work order and uplift updates both ran inside the save's transaction before // the commit failed; neither may survive it. Assert.True(writes.UpdatedUplift); Assert.True(writes.UpdatedWorkOrder); writes.Armed = false; failCommit.Armed = false; await using var verify = new WorkOrderBoardPatchCancelUpliftTests.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 UpdateRecorder : DbCommandInterceptor { public bool Armed { get; set; } public bool UpdatedUplift { get; private set; } public bool UpdatedWorkOrder { get; private set; } public override ValueTask> ReaderExecutingAsync( DbCommand command, CommandEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { if (Armed && command.Transaction is not null) { UpdatedUplift |= command.CommandText.Contains("UPDATE \"DispatchUpliftRequests\""); UpdatedWorkOrder |= command.CommandText.Contains("UPDATE \"workOrders\""); } return base.ReaderExecutingAsync(command, eventData, result, cancellationToken); } } private sealed class FailCommitInterceptor : DbTransactionInterceptor { public bool Armed { get; set; } public override ValueTask TransactionCommittingAsync( DbTransaction transaction, TransactionEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { if (Armed) throw new InvalidOperationException("Simulated failure before the commit"); return base.TransactionCommittingAsync(transaction, eventData, result, cancellationToken); } } }