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); } }