mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
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.
This commit is contained in:
parent
9bad363930
commit
99499c3281
10 changed files with 356 additions and 54 deletions
79
SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs
Normal file
79
SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SeaHaven.DataServices.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <see cref="Applies"/> 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.
|
||||
/// </summary>
|
||||
public static class PendingUpliftCancellation
|
||||
{
|
||||
public const string AuditAction = "uplift_cancel";
|
||||
public const string CancelledStatus = "Withdrawn";
|
||||
|
||||
private static readonly string[] PendingStatuses = { "Pending", "ChangesRequested" };
|
||||
|
||||
/// <summary>True when a lifecycle change moves a work order into Canceled.</summary>
|
||||
public static bool Applies(LifecycleStatus? before, LifecycleStatus? after)
|
||||
=> before != LifecycleStatus.Canceled && after == LifecycleStatus.Canceled;
|
||||
|
||||
/// <summary>
|
||||
/// Uplift requests that belong to a work order: on a live dispatch that the work
|
||||
/// order owns or is linked to.
|
||||
/// </summary>
|
||||
internal static IQueryable<DispatchUpliftRequest> 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)));
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static async Task<int> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -455,15 +455,18 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.SumAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(
|
||||
public Task<int> 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<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
|
|
@ -524,15 +527,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
}
|
||||
|
||||
private IQueryable<DispatchUpliftRequest> 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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken);
|
||||
Task<List<DispatchUpliftRequest>> 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<int> StageCancelPendingForWorkOrderAsync(
|
||||
int workOrderId,
|
||||
string? actorId,
|
||||
DateTime now,
|
||||
CancellationToken cancellationToken);
|
||||
// SH-101: active = Pending or ChangesRequested (the only states that block a new request).
|
||||
Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<WorkOrderUpliftDto> PersistCreatedAsync(
|
||||
|
|
|
|||
|
|
@ -461,7 +461,7 @@ public class WorkOrderBoardCancelServiceTests
|
|||
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<int> StageCancelPendingForWorkOrderAsync(int workOrderId, string? actorId, DateTime now, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<DispatchUpliftRequest?> GetByRequestKeyAsync(int dispatchId, string requestKey, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
|
|
|
|||
|
|
@ -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<ApplicationDbContext> options)
|
||||
: base(options)
|
||||
|
|
|
|||
222
SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs
Normal file
222
SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<DbContextOptions<ApplicationDbContext>> SeedInMemoryAsync(
|
||||
params DispatchUpliftRequest[] uplifts)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.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<ApplicationDbContext>()
|
||||
.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<InvalidOperationException>(() => 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<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
|
||||
DbCommand command,
|
||||
CommandEventData eventData,
|
||||
InterceptionResult<DbDataReader> 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<InterceptionResult> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -216,6 +216,16 @@ public sealed class WorkOrderReconciliationTests
|
|||
Assert.Equal("winner", (await context.workOrders.SingleAsync()).WorkerOrderTitle);
|
||||
}
|
||||
|
||||
internal static WorkOrderReconciliationService CreateService(
|
||||
IReadOnlyList<ProcurementWorkOrder> workOrders,
|
||||
IWorkOrderWebhookDataService data) =>
|
||||
Create(
|
||||
new MockClient(
|
||||
new[] { new ProcurementPage<ProcurementWorkOrder>(workOrders, null) },
|
||||
new()),
|
||||
data,
|
||||
new RecordingJobs());
|
||||
|
||||
private static WorkOrderReconciliationService Create(
|
||||
IProcurementWorkOrderClient client,
|
||||
IWorkOrderWebhookDataService workOrders,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue