shoc-backend/SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs
Alexandre Brandizzi a32471d4c0 Serialize sync cancels and vendor uplift requests on the work order lock
The legacy ingest batch holds the per-work-order lock, taken in id order,
for every work order it may cancel until the batch commits. A vendor
uplift request now runs under the same lock. Uplift creation on both
routes refuses a work order cancelled by either lifecycle or status text,
so a request can neither slip past a cancel nor land after one.
2026-09-25 19:37:08 -03:00

381 lines
17 KiB
C#

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;
/// <summary>
/// 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.
/// </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 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<ApplicationDbContext>()
.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<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);
}
}
}