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(); private static EquipmentCsvRow Row(string zTag, string name = "eq-1") => new() { ZTag = zTag, MachineCode = "mc", SAPID = "sap", 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 } }