From 99499c3281d4c7eba3bbd55e1b82cb0942e80e99 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 18:48:44 -0300 Subject: [PATCH] Cancel pending uplifts when the CRM cancels a work order The webhook and reconciliation saves now stage the same pending-uplift cancellation, with its own sync audit row, as the board cancel. The rule and the write live in one data-layer helper so the paths cannot drift. --- .../Helpers/PendingUpliftCancellation.cs | 79 +++++++ .../Implementation/UpliftDataService.cs | 27 +-- .../WorkOrderWebhookDataService.cs | 16 ++ .../Interfaces/IUpliftDataService.cs | 8 +- .../WorkOrderBoardUpdateService.cs | 3 +- .../Implementation/WorkOrderUpliftService.cs | 36 +-- .../WorkOrderBoardCancelServiceTests.cs | 2 +- .../WorkOrderBoardPatchCancelUpliftTests.cs | 7 +- .../WorkOrderCrmCancelUpliftTests.cs | 222 ++++++++++++++++++ .../WorkOrderReconciliationTests.cs | 10 + 10 files changed, 356 insertions(+), 54 deletions(-) create mode 100644 SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs diff --git a/SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs b/SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs new file mode 100644 index 0000000..1a4e3c4 --- /dev/null +++ b/SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs @@ -0,0 +1,79 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; + +namespace SeaHaven.DataServices.Helpers +{ + /// + /// The single rule and write for "a work order that becomes Canceled cancels its pending + /// uplifts in the same action". Every path that can cancel a work order (board update, + /// the cancel endpoint, CRM webhook and reconciliation) asks and + /// stages the cancellation into its own unit of work, so the uplift change and its own + /// audit row commit, or roll back, with the work order's status change. + /// + public static class PendingUpliftCancellation + { + public const string AuditAction = "uplift_cancel"; + public const string CancelledStatus = "Withdrawn"; + + private static readonly string[] PendingStatuses = { "Pending", "ChangesRequested" }; + + /// True when a lifecycle change moves a work order into Canceled. + public static bool Applies(LifecycleStatus? before, LifecycleStatus? after) + => before != LifecycleStatus.Canceled && after == LifecycleStatus.Canceled; + + /// + /// Uplift requests that belong to a work order: on a live dispatch that the work + /// order owns or is linked to. + /// + internal static IQueryable ForWorkOrder(ApplicationDbContext context, int workOrderId) + => context.DispatchUpliftRequests.Where(u => + (u.IsDeleted == null || u.IsDeleted == false) + && u.Dispatch != null + && (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false) + && ( + u.Dispatch.WorkOrderId == workOrderId + || u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == workOrderId))); + + /// + /// Stages (does not save) the cancellation of every pending uplift on the work order, + /// with one audit row per uplift. Returns how many uplifts were cancelled. + /// + internal static async Task StageAsync( + ApplicationDbContext context, + int workOrderId, + string? actorId, + string actorType, + DateTime now, + CancellationToken cancellationToken) + { + var pending = await ForWorkOrder(context, workOrderId) + .Include(u => u.Dispatch) + .Where(u => PendingStatuses.Contains(u.Status)) + .ToListAsync(cancellationToken); + + foreach (var request in pending) + { + var previous = request.Status; + request.Status = CancelledStatus; + request.DecidedAt = now; + request.DecidedByUserId = actorId; + request.LastModificationTime = now; + + context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + { + WorkOrderId = workOrderId, + UserId = actorId, + FieldName = $"Dispatch {request.Dispatch!.DispatchNumber} Uplift", + OldValue = previous, + NewValue = CancelledStatus, + Action = AuditAction, + ActorType = actorType, + CreatedAt = now, + }); + } + + return pending.Count; + } + } +} diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 0c3706c..3f9cf21 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -455,15 +455,18 @@ namespace SeaHaven.DataServices.Implementation .SumAsync(cancellationToken); } - public Task> GetPendingForWorkOrderAsync( + public Task StageCancelPendingForWorkOrderAsync( int workOrderId, + string? actorId, + DateTime now, CancellationToken cancellationToken) - { - return ForWorkOrder(workOrderId) - .Include(u => u.Dispatch) - .Where(u => u.Status == "Pending" || u.Status == "ChangesRequested") - .ToListAsync(cancellationToken); - } + => PendingUpliftCancellation.StageAsync( + _context, + workOrderId, + actorId, + actorType: "internal", + now, + cancellationToken); public async Task HasActiveAsync(int dispatchId, CancellationToken cancellationToken) { @@ -524,15 +527,7 @@ namespace SeaHaven.DataServices.Implementation } private IQueryable ForWorkOrder(int workOrderId) - { - return _context.DispatchUpliftRequests.Where(u => - (u.IsDeleted == null || u.IsDeleted == false) - && u.Dispatch != null - && (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false) - && ( - u.Dispatch.WorkOrderId == workOrderId - || u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == workOrderId))); - } + => PendingUpliftCancellation.ForWorkOrder(_context, workOrderId); public async Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken) { diff --git a/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs index f32ebd0..49cdc7b 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs @@ -1,6 +1,8 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation @@ -112,8 +114,22 @@ namespace SeaHaven.DataServices.Implementation workOrder.WorkerOrderTitle = mutation.Title ?? mutation.Description; workOrder.Description = mutation.Description; workOrder.Status = mutation.IsCancelled ? "Cancelled" : mutation.Status; + var lifecycleBefore = workOrder.LifecycleStatus; if (mutation.LifecycleStatus.HasValue) workOrder.LifecycleStatus = mutation.LifecycleStatus.Value; + + // A CRM cancellation cancels the work order's pending uplifts in this same save. + if (workOrder.Id > 0 + && PendingUpliftCancellation.Applies(lifecycleBefore, workOrder.LifecycleStatus)) + { + await PendingUpliftCancellation.StageAsync( + _context, + workOrder.Id, + actorId: null, + actorType: AuditActorType.Sync.ToString(), + mutation.ProcessedAt.UtcDateTime, + cancellationToken); + } workOrder.Severity = mutation.Severity; workOrder.Priority = mutation.Priority; workOrder.ExternalAssignedTo = mutation.AssignedTo; diff --git a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs index 4dfe871..1053202 100644 --- a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs @@ -32,7 +32,13 @@ namespace SeaHaven.DataServices.Interfaces // SH-207: queue-wide pending exposure for the approvals header (sum of // RequestedNTE over non-deleted Pending requests on non-deleted dispatches). Task GetPendingExposureTotalAsync(CancellationToken cancellationToken); - Task> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); + // Stages (does not save) the cancellation of the work order's pending uplifts, each + // with its own audit row, for the caller's unit of work. See PendingUpliftCancellation. + Task StageCancelPendingForWorkOrderAsync( + int workOrderId, + string? actorId, + DateTime now, + CancellationToken cancellationToken); // SH-101: active = Pending or ChangesRequested (the only states that block a new request). Task HasActiveAsync(int dispatchId, CancellationToken cancellationToken); Task GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken); diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index d34c6fb..3a194fc 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -139,8 +139,7 @@ namespace SeaHaven.Services.Implementation // Cancelling the work order cancels its pending uplift in the same action: the // withdrawal and its own audit entry are staged here and committed by the same // save as the status change. - if (lifecycleBefore != LifecycleStatus.Canceled - && workOrder.LifecycleStatus == LifecycleStatus.Canceled) + if (PendingUpliftCancellation.Applies(lifecycleBefore, workOrder.LifecycleStatus)) { await _upliftService.WithdrawPendingForWorkOrderAsync(workOrderId, actorId, ct); } diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 0980e0d..79da4a2 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -345,37 +345,11 @@ namespace SeaHaven.Services.Implementation string? actorId, CancellationToken cancellationToken) { - var pending = await _upliftData.GetPendingForWorkOrderAsync(workOrderId, cancellationToken); - if (pending.Count == 0) - return; - - var now = _timeProvider.GetUtcNow().UtcDateTime; - foreach (var req in pending) - { - if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Withdrawn)) - continue; - - var dispatch = req.Dispatch ?? await _dispatchData.GetByIdAsync(req.DispatchId); - if (dispatch == null) - continue; - - var previous = UpliftStatus.ToCanonical(req.Status); - req.Status = UpliftStatus.Withdrawn; - req.DecidedAt = now; - req.DecidedByUserId = actorId; - req.LastModificationTime = now; - - await StageAuditAsync( - dispatch, - workOrderId, - actorId, - previous, - UpliftStatus.Withdrawn, - "uplift_cancel", - now, - cancellationToken, - isStatusTransition: true); - } + await _upliftData.StageCancelPendingForWorkOrderAsync( + workOrderId, + actorId, + _timeProvider.GetUtcNow().UtcDateTime, + cancellationToken); } private async Task PersistCreatedAsync( diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index 01e93df..76e521a 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -461,7 +461,7 @@ public class WorkOrderBoardCancelServiceTests 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 StageCancelPendingForWorkOrderAsync(int workOrderId, string? actorId, DateTime now, 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(); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardPatchCancelUpliftTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardPatchCancelUpliftTests.cs index e2ad1f3..c2f68aa 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardPatchCancelUpliftTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardPatchCancelUpliftTests.cs @@ -40,7 +40,7 @@ public sealed class WorkOrderBoardPatchCancelUpliftTests // Saved in stages: the work order and its primary dispatch reference each other, which a // relational provider cannot insert in one statement batch. - private static async Task SeedWorkOrderAsync(ApplicationDbContext context) + 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" }); @@ -50,6 +50,7 @@ public sealed class WorkOrderBoardPatchCancelUpliftTests { Id = 1, InternalWONumber = "10000000001", + ExternalWorkOrderId = "123", LifecycleStatus = LifecycleStatus.Incomplete, Status = "Incomplete", AccountId = 1, @@ -73,7 +74,7 @@ public sealed class WorkOrderBoardPatchCancelUpliftTests await context.SaveChangesAsync(); } - private static DispatchUpliftRequest Uplift(int id, string status, decimal amount) => new() + internal static DispatchUpliftRequest Uplift(int id, string status, decimal amount) => new() { Id = id, DispatchId = 10, @@ -231,7 +232,7 @@ public sealed class WorkOrderBoardPatchCancelUpliftTests } } - private sealed class SqliteRowVersionDbContext : ApplicationDbContext + internal sealed class SqliteRowVersionDbContext : ApplicationDbContext { public SqliteRowVersionDbContext(DbContextOptions options) : base(options) diff --git a/SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs b/SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs new file mode 100644 index 0000000..f8ae2a1 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs @@ -0,0 +1,222 @@ +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.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) 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 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 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); + } + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderReconciliationTests.cs b/SeaHavenIndustries.Tests/WorkOrderReconciliationTests.cs index 331ab47..fd37400 100644 --- a/SeaHavenIndustries.Tests/WorkOrderReconciliationTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderReconciliationTests.cs @@ -216,6 +216,16 @@ public sealed class WorkOrderReconciliationTests Assert.Equal("winner", (await context.workOrders.SingleAsync()).WorkerOrderTitle); } + internal static WorkOrderReconciliationService CreateService( + IReadOnlyList workOrders, + IWorkOrderWebhookDataService data) => + Create( + new MockClient( + new[] { new ProcurementPage(workOrders, null) }, + new()), + data, + new RecordingJobs()); + private static WorkOrderReconciliationService Create( IProcurementWorkOrderClient client, IWorkOrderWebhookDataService workOrders,