Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentImportBatchServiceTests.cs
Joseph Doherty 020c30f9a6 feat(admin): add ZTag/SAPID reservation pre-check to equipment CSV import (task #197)
ApplyReservationPreCheckAsync on EquipmentImportBatchService queries active
ExternalIdReservation rows in a single round-trip at parse time; rows whose ZTag
or SAPID is claimed by a different EquipmentUuid are moved from AcceptedRows to
RejectedRows with a descriptive reason. ImportEquipment.razor calls the check
after EquipmentCsvImporter.Parse so conflicts appear in the preview before the
operator clicks Stage + Finalise. Updated notice banner to reflect the pre-check
is now live; 6 new unit tests cover conflict, no-conflict, same-UUID, released-
reservation, and empty-input paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 04:24:29 -04:00

433 lines
18 KiB
C#

using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class EquipmentImportBatchServiceTests : IDisposable
{
private readonly OtOpcUaConfigDbContext _db;
private readonly EquipmentImportBatchService _svc;
public EquipmentImportBatchServiceTests()
{
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase($"import-batch-{Guid.NewGuid():N}")
.Options;
_db = new OtOpcUaConfigDbContext(options);
_svc = new EquipmentImportBatchService(_db);
}
public void Dispose() => _db.Dispose();
// Unique SAPID per row — FinaliseBatch reserves ZTag + SAPID via filtered-unique index, so
// two rows sharing a SAPID under different EquipmentUuids collide as intended.
private static EquipmentCsvRow Row(string zTag, string name = "eq-1") => new()
{
ZTag = zTag,
MachineCode = "mc",
SAPID = $"sap-{zTag}",
EquipmentId = "eq-id",
EquipmentUuid = Guid.NewGuid().ToString(),
Name = name,
UnsAreaName = "area",
UnsLineName = "line",
};
[Fact]
public async Task CreateBatch_PopulatesId_AndTimestamp()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
batch.Id.ShouldNotBe(Guid.Empty);
batch.CreatedAtUtc.ShouldBeGreaterThan(DateTime.UtcNow.AddMinutes(-1));
batch.RowsStaged.ShouldBe(0);
}
[Fact]
public async Task StageRows_AcceptedAndRejected_AllPersist()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id,
acceptedRows: [Row("z-1"), Row("z-2")],
rejectedRows: [new EquipmentCsvRowError(LineNumber: 5, Reason: "duplicate ZTag")],
CancellationToken.None);
var reloaded = await _db.EquipmentImportBatches.Include(b => b.Rows).FirstAsync(b => b.Id == batch.Id);
reloaded.RowsStaged.ShouldBe(3);
reloaded.RowsAccepted.ShouldBe(2);
reloaded.RowsRejected.ShouldBe(1);
reloaded.Rows.Count.ShouldBe(3);
reloaded.Rows.Count(r => r.IsAccepted).ShouldBe(2);
reloaded.Rows.Single(r => !r.IsAccepted).RejectReason.ShouldBe("duplicate ZTag");
}
[Fact]
public async Task DropBatch_RemovesBatch_AndCascades_Rows()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None);
await _svc.DropBatchAsync(batch.Id, CancellationToken.None);
(await _db.EquipmentImportBatches.AnyAsync(b => b.Id == batch.Id)).ShouldBeFalse();
(await _db.EquipmentImportRows.AnyAsync(r => r.BatchId == batch.Id)).ShouldBeFalse("cascaded delete clears rows");
}
[Fact]
public async Task DropBatch_AfterFinalise_Throws()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batch.Id, generationId: 1, driverInstanceIdForRows: "drv-1", unsLineIdForRows: "line-1", CancellationToken.None);
await Should.ThrowAsync<ImportBatchAlreadyFinalisedException>(
() => _svc.DropBatchAsync(batch.Id, CancellationToken.None));
}
[Fact]
public async Task Finalise_AcceptedRows_BecomeEquipment()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id,
[Row("z-1", name: "alpha"), Row("z-2", name: "beta")],
rejectedRows: [new EquipmentCsvRowError(1, "rejected")],
CancellationToken.None);
await _svc.FinaliseBatchAsync(batch.Id, 5, "drv-modbus", "line-warsaw", CancellationToken.None);
var equipment = await _db.Equipment.Where(e => e.GenerationId == 5).ToListAsync();
equipment.Count.ShouldBe(2);
equipment.Select(e => e.Name).ShouldBe(["alpha", "beta"], ignoreOrder: true);
equipment.All(e => e.DriverInstanceId == "drv-modbus").ShouldBeTrue();
equipment.All(e => e.UnsLineId == "line-warsaw").ShouldBeTrue();
var reloaded = await _db.EquipmentImportBatches.FirstAsync(b => b.Id == batch.Id);
reloaded.FinalisedAtUtc.ShouldNotBeNull();
}
[Fact]
public async Task Finalise_Twice_Throws()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None);
await Should.ThrowAsync<ImportBatchAlreadyFinalisedException>(
() => _svc.FinaliseBatchAsync(batch.Id, 2, "drv", "line", CancellationToken.None));
}
[Fact]
public async Task Finalise_MissingBatch_Throws()
{
await Should.ThrowAsync<ImportBatchNotFoundException>(
() => _svc.FinaliseBatchAsync(Guid.NewGuid(), 1, "drv", "line", CancellationToken.None));
}
[Fact]
public async Task Stage_After_Finalise_Throws()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None);
await Should.ThrowAsync<ImportBatchAlreadyFinalisedException>(
() => _svc.StageRowsAsync(batch.Id, [Row("z-2")], [], CancellationToken.None));
}
[Fact]
public async Task ListByUser_FiltersByCreator_AndFinalised()
{
var a = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
var b = await _svc.CreateBatchAsync("c1", "bob", CancellationToken.None);
await _svc.StageRowsAsync(a.Id, [Row("z-a")], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(a.Id, 1, "d", "l", CancellationToken.None);
_ = b;
var aliceOpen = await _svc.ListByUserAsync("alice", includeFinalised: false, CancellationToken.None);
aliceOpen.ShouldBeEmpty("alice's only batch is finalised");
var aliceAll = await _svc.ListByUserAsync("alice", includeFinalised: true, CancellationToken.None);
aliceAll.Count.ShouldBe(1);
var bobOpen = await _svc.ListByUserAsync("bob", includeFinalised: false, CancellationToken.None);
bobOpen.Count.ShouldBe(1);
}
[Fact]
public async Task DropBatch_Unknown_IsNoOp()
{
await _svc.DropBatchAsync(Guid.NewGuid(), CancellationToken.None);
// no throw
}
[Fact]
public async Task FinaliseBatch_Creates_ExternalIdReservations_ForZTagAndSAPID()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id, [Row("z-new-1")], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None);
var active = await _db.ExternalIdReservations.AsNoTracking()
.Where(r => r.ReleasedAt == null)
.ToListAsync();
active.Count.ShouldBe(2);
active.ShouldContain(r => r.Kind == ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.ZTag && r.Value == "z-new-1");
active.ShouldContain(r => r.Kind == ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.SAPID && r.Value == "sap-z-new-1");
}
[Fact]
public async Task FinaliseBatch_SameEquipmentUuid_ReusesExistingReservation()
{
var batch1 = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
var sharedUuid = Guid.NewGuid();
var row = new EquipmentCsvRow
{
ZTag = "z-shared", MachineCode = "mc", SAPID = "sap-shared",
EquipmentId = "eq-1", EquipmentUuid = sharedUuid.ToString(),
Name = "eq-1", UnsAreaName = "a", UnsLineName = "l",
};
await _svc.StageRowsAsync(batch1.Id, [row], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batch1.Id, 1, "drv", "line", CancellationToken.None);
var countAfterFirst = _db.ExternalIdReservations.Count(r => r.ReleasedAt == null);
// Second finalise with same EquipmentUuid + same ZTag — should NOT create a duplicate.
var batch2 = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch2.Id, [row], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batch2.Id, 2, "drv", "line", CancellationToken.None);
_db.ExternalIdReservations.Count(r => r.ReleasedAt == null).ShouldBe(countAfterFirst);
}
[Fact]
public async Task FinaliseBatch_DifferentEquipmentUuid_SameZTag_Throws_Conflict()
{
var batchA = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
var rowA = new EquipmentCsvRow
{
ZTag = "z-collide", MachineCode = "mc-a", SAPID = "sap-a",
EquipmentId = "eq-a", EquipmentUuid = Guid.NewGuid().ToString(),
Name = "a", UnsAreaName = "ar", UnsLineName = "ln",
};
await _svc.StageRowsAsync(batchA.Id, [rowA], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batchA.Id, 1, "drv", "line", CancellationToken.None);
var batchB = await _svc.CreateBatchAsync("c1", "bob", CancellationToken.None);
var rowB = new EquipmentCsvRow
{
ZTag = "z-collide", MachineCode = "mc-b", SAPID = "sap-b", // same ZTag, different EquipmentUuid
EquipmentId = "eq-b", EquipmentUuid = Guid.NewGuid().ToString(),
Name = "b", UnsAreaName = "ar", UnsLineName = "ln",
};
await _svc.StageRowsAsync(batchB.Id, [rowB], [], CancellationToken.None);
var ex = await Should.ThrowAsync<ExternalIdReservationConflictException>(() =>
_svc.FinaliseBatchAsync(batchB.Id, 2, "drv", "line", CancellationToken.None));
ex.Message.ShouldContain("z-collide");
// Second finalise must have rolled back — no partial Equipment row for batch B.
var equipmentB = await _db.Equipment.AsNoTracking()
.Where(e => e.EquipmentId == "eq-b")
.ToListAsync();
equipmentB.ShouldBeEmpty();
}
[Fact]
public async Task FinaliseBatch_EmptyZTagAndSAPID_SkipsReservation()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
var row = new EquipmentCsvRow
{
ZTag = "", MachineCode = "mc", SAPID = "",
EquipmentId = "eq-nil", EquipmentUuid = Guid.NewGuid().ToString(),
Name = "nil", UnsAreaName = "ar", UnsLineName = "ln",
};
await _svc.StageRowsAsync(batch.Id, [row], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None);
_db.ExternalIdReservations.Count().ShouldBe(0);
}
// ── ApplyReservationPreCheck tests ──────────────────────────────────────────
/// <summary>No active reservations → the parse result passes through unchanged.</summary>
[Fact]
public async Task PreCheck_NoReservations_ReturnsUnchanged()
{
var input = new EquipmentCsvParseResult(
AcceptedRows: [Row("z-1"), Row("z-2")],
RejectedRows: []);
var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None);
result.AcceptedRows.Count.ShouldBe(2);
result.RejectedRows.Count.ShouldBe(0);
}
/// <summary>
/// ZTag reserved by a DIFFERENT EquipmentUuid → row moves to rejected with a descriptive reason;
/// SAPID of that same row is ignored since the row is already conflicted.
/// </summary>
[Fact]
public async Task PreCheck_ZTagConflict_MovesRowToRejected()
{
// Seed an active reservation for "z-taken" owned by a different UUID.
var ownerUuid = Guid.NewGuid();
_db.ExternalIdReservations.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation
{
ReservationId = Guid.NewGuid(),
Kind = ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.ZTag,
Value = "z-taken",
EquipmentUuid = ownerUuid,
ClusterId = "c1",
FirstPublishedBy = "alice",
});
await _db.SaveChangesAsync();
var importerUuid = Guid.NewGuid(); // different UUID — conflict
var conflictRow = new EquipmentCsvRow
{
ZTag = "z-taken", MachineCode = "mc", SAPID = "sap-ok",
EquipmentId = "eq-x", EquipmentUuid = importerUuid.ToString(),
Name = "x", UnsAreaName = "ar", UnsLineName = "ln",
};
var cleanRow = Row("z-clean");
var input = new EquipmentCsvParseResult(
AcceptedRows: [conflictRow, cleanRow],
RejectedRows: [new EquipmentCsvRowError(99, "pre-existing parser rejection")]);
var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None);
result.AcceptedRows.Count.ShouldBe(1, "only the clean row remains accepted");
result.AcceptedRows[0].ZTag.ShouldBe("z-clean");
result.RejectedRows.Count.ShouldBe(2, "pre-existing + the new conflict rejection");
var conflictError = result.RejectedRows.Single(e => e.Reason.Contains("z-taken"));
conflictError.Reason.ShouldContain(ownerUuid.ToString());
conflictError.Reason.ShouldContain("ZTag");
}
/// <summary>SAPID reserved by a different EquipmentUuid → row is rejected with a SAPID-specific reason.</summary>
[Fact]
public async Task PreCheck_SAPIDConflict_MovesRowToRejected()
{
var ownerUuid = Guid.NewGuid();
_db.ExternalIdReservations.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation
{
ReservationId = Guid.NewGuid(),
Kind = ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.SAPID,
Value = "sap-taken",
EquipmentUuid = ownerUuid,
ClusterId = "c1",
FirstPublishedBy = "alice",
});
await _db.SaveChangesAsync();
var importerUuid = Guid.NewGuid();
var conflictRow = new EquipmentCsvRow
{
ZTag = "z-free", MachineCode = "mc", SAPID = "sap-taken",
EquipmentId = "eq-y", EquipmentUuid = importerUuid.ToString(),
Name = "y", UnsAreaName = "ar", UnsLineName = "ln",
};
var input = new EquipmentCsvParseResult(AcceptedRows: [conflictRow], RejectedRows: []);
var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None);
result.AcceptedRows.Count.ShouldBe(0);
result.RejectedRows.Count.ShouldBe(1);
result.RejectedRows[0].Reason.ShouldContain("sap-taken");
result.RejectedRows[0].Reason.ShouldContain("SAPID");
result.RejectedRows[0].Reason.ShouldContain(ownerUuid.ToString());
}
/// <summary>
/// Reservation active for the SAME EquipmentUuid → row is NOT rejected (normal re-publish).
/// </summary>
[Fact]
public async Task PreCheck_SameEquipmentUuid_NotFlagged()
{
var sharedUuid = Guid.NewGuid();
_db.ExternalIdReservations.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation
{
ReservationId = Guid.NewGuid(),
Kind = ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.ZTag,
Value = "z-mine",
EquipmentUuid = sharedUuid,
ClusterId = "c1",
FirstPublishedBy = "alice",
});
await _db.SaveChangesAsync();
var row = new EquipmentCsvRow
{
ZTag = "z-mine", MachineCode = "mc", SAPID = "sap-mine",
EquipmentId = "eq-z", EquipmentUuid = sharedUuid.ToString(), // same UUID
Name = "z", UnsAreaName = "ar", UnsLineName = "ln",
};
var input = new EquipmentCsvParseResult(AcceptedRows: [row], RejectedRows: []);
var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None);
result.AcceptedRows.Count.ShouldBe(1, "same UUID → not a conflict");
result.RejectedRows.Count.ShouldBe(0);
}
/// <summary>A released reservation (ReleasedAt IS NOT NULL) does not block the import row.</summary>
[Fact]
public async Task PreCheck_ReleasedReservation_IsIgnored()
{
var oldOwner = Guid.NewGuid();
_db.ExternalIdReservations.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation
{
ReservationId = Guid.NewGuid(),
Kind = ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.ZTag,
Value = "z-released",
EquipmentUuid = oldOwner,
ClusterId = "c1",
FirstPublishedBy = "alice",
ReleasedAt = DateTime.UtcNow.AddDays(-1),
ReleasedBy = "bob",
ReleaseReason = "decommissioned",
});
await _db.SaveChangesAsync();
var newImporterUuid = Guid.NewGuid();
var row = new EquipmentCsvRow
{
ZTag = "z-released", MachineCode = "mc", SAPID = "sap-new",
EquipmentId = "eq-new", EquipmentUuid = newImporterUuid.ToString(),
Name = "new", UnsAreaName = "ar", UnsLineName = "ln",
};
var input = new EquipmentCsvParseResult(AcceptedRows: [row], RejectedRows: []);
var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None);
result.AcceptedRows.Count.ShouldBe(1, "released reservation is free to claim");
result.RejectedRows.Count.ShouldBe(0);
}
/// <summary>Empty accepted list short-circuits without hitting the DB.</summary>
[Fact]
public async Task PreCheck_EmptyInput_ReturnsUnchanged()
{
var input = new EquipmentCsvParseResult(
AcceptedRows: [],
RejectedRows: [new EquipmentCsvRowError(1, "already rejected")]);
var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None);
result.ShouldBeSameAs(input, "same instance when there is nothing to check");
}
}