mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-03 20:13:22 +00:00
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.
381 lines
17 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|