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() .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( () => _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( () => _svc.FinaliseBatchAsync(batch.Id, 2, "drv", "line", CancellationToken.None)); } [Fact] public async Task Finalise_MissingBatch_Throws() { await Should.ThrowAsync( () => _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( () => _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(() => _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 ────────────────────────────────────────── /// No active reservations → the parse result passes through unchanged. [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); } /// /// 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. /// [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"); } /// SAPID reserved by a different EquipmentUuid → row is rejected with a SAPID-specific reason. [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()); } /// /// Reservation active for the SAME EquipmentUuid → row is NOT rejected (normal re-publish). /// [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); } /// A released reservation (ReleasedAt IS NOT NULL) does not block the import row. [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); } /// Empty accepted list short-circuits without hitting the DB. [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"); } }