mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-04 19:32:10 +00:00
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.
222 lines
9.8 KiB
C#
222 lines
9.8 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.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);
|
|
}
|
|
}
|
|
}
|