using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Admin.Services; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.Admin.Tests; /// /// Ties Admin services end-to-end against a throwaway per-run database — mirrors the /// Configuration fixture pattern. Spins up a fresh DB, applies migrations, exercises the /// create-cluster → add-equipment → validate → publish → rollback happy path, then drops the /// DB in Dispose. Confirms the stored procedures and managed validators agree with the UI /// services. /// [Trait("Category", "Integration")] public sealed class AdminServicesIntegrationTests : IDisposable { private const string DefaultServer = "localhost,14330"; private const string DefaultSaPassword = "OtOpcUaDev_2026!"; private readonly string _databaseName = $"OtOpcUaAdminTest_{Guid.NewGuid():N}"; private readonly string _connectionString; public AdminServicesIntegrationTests() { var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer; var password = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword; _connectionString = $"Server={server};Database={_databaseName};User Id=sa;Password={password};TrustServerCertificate=True;Encrypt=False;"; using var ctx = NewContext(); ctx.Database.Migrate(); } public void Dispose() { using var conn = new Microsoft.Data.SqlClient.SqlConnection( new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(_connectionString) { InitialCatalog = "master" }.ConnectionString); conn.Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = $@" IF DB_ID(N'{_databaseName}') IS NOT NULL BEGIN ALTER DATABASE [{_databaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_databaseName}]; END"; cmd.ExecuteNonQuery(); } private OtOpcUaConfigDbContext NewContext() { var opts = new DbContextOptionsBuilder() .UseSqlServer(_connectionString) .Options; return new OtOpcUaConfigDbContext(opts); } [Fact] public async Task Create_cluster_add_equipment_validate_publish_roundtrips_the_full_admin_flow() { // 1. Create cluster + draft. await using (var ctx = NewContext()) { var clusterSvc = new ClusterService(ctx); await clusterSvc.CreateAsync(new ServerCluster { ClusterId = "flow-1", Name = "Flow test", Enterprise = "zb", Site = "dev", NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true, CreatedBy = "test", }, createdBy: "test", CancellationToken.None); } long draftId; await using (var ctx = NewContext()) { var genSvc = new GenerationService(ctx); var draft = await genSvc.CreateDraftAsync("flow-1", "test", CancellationToken.None); draftId = draft.GenerationId; } // 2. Add namespace + UNS + driver + equipment. await using (var ctx = NewContext()) { var nsSvc = new NamespaceService(ctx); var unsSvc = new UnsService(ctx); var drvSvc = new DriverInstanceService(ctx); var eqSvc = new EquipmentService(ctx); var ns = await nsSvc.AddAsync(draftId, "flow-1", "urn:flow:ns", NamespaceKind.Equipment, CancellationToken.None); var area = await unsSvc.AddAreaAsync(draftId, "flow-1", "line-a", null, CancellationToken.None); var line = await unsSvc.AddLineAsync(draftId, area.UnsAreaId, "cell-1", null, CancellationToken.None); var driver = await drvSvc.AddAsync(draftId, "flow-1", ns.NamespaceId, "modbus", "ModbusTcp", "{}", CancellationToken.None); await eqSvc.CreateAsync(draftId, new Equipment { EquipmentUuid = Guid.NewGuid(), EquipmentId = string.Empty, DriverInstanceId = driver.DriverInstanceId, UnsLineId = line.UnsLineId, Name = "eq-1", MachineCode = "M001", }, CancellationToken.None); } // 3. Validate — should be error-free. await using (var ctx = NewContext()) { var validationSvc = new DraftValidationService(ctx); var errors = await validationSvc.ValidateAsync(draftId, CancellationToken.None); errors.ShouldBeEmpty("draft with matched namespace/driver should validate clean"); } // 4. Publish + verify status flipped. await using (var ctx = NewContext()) { var genSvc = new GenerationService(ctx); await genSvc.PublishAsync("flow-1", draftId, "first publish", CancellationToken.None); } await using (var ctx = NewContext()) { var status = await ctx.ConfigGenerations .Where(g => g.GenerationId == draftId) .Select(g => g.Status) .FirstAsync(); status.ShouldBe(GenerationStatus.Published); } // 5. Rollback creates a new Published generation cloned from the target. await using (var ctx = NewContext()) { var genSvc = new GenerationService(ctx); await genSvc.RollbackAsync("flow-1", draftId, "rollback test", CancellationToken.None); } await using (var ctx = NewContext()) { var publishedCount = await ctx.ConfigGenerations .CountAsync(g => g.ClusterId == "flow-1" && g.Status == GenerationStatus.Published); publishedCount.ShouldBe(1, "rollback supersedes the prior publish with a new one"); var supersededCount = await ctx.ConfigGenerations .CountAsync(g => g.ClusterId == "flow-1" && g.Status == GenerationStatus.Superseded); supersededCount.ShouldBeGreaterThanOrEqualTo(1); } } [Fact] public async Task Validate_draft_surfaces_cross_cluster_namespace_binding_violation() { await using (var ctx = NewContext()) { await new ClusterService(ctx).CreateAsync(new ServerCluster { ClusterId = "c-A", Name = "A", Enterprise = "zb", Site = "dev", NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true, CreatedBy = "t", }, "t", CancellationToken.None); await new ClusterService(ctx).CreateAsync(new ServerCluster { ClusterId = "c-B", Name = "B", Enterprise = "zb", Site = "dev", NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true, CreatedBy = "t", }, "t", CancellationToken.None); } long draftId; await using (var ctx = NewContext()) { var draft = await new GenerationService(ctx).CreateDraftAsync("c-A", "t", CancellationToken.None); draftId = draft.GenerationId; } await using (var ctx = NewContext()) { // Namespace rooted in c-B, driver in c-A — decision #122 violation. var ns = await new NamespaceService(ctx) .AddAsync(draftId, "c-B", "urn:cross", NamespaceKind.Equipment, CancellationToken.None); await new DriverInstanceService(ctx) .AddAsync(draftId, "c-A", ns.NamespaceId, "drv", "ModbusTcp", "{}", CancellationToken.None); } await using (var ctx = NewContext()) { var errors = await new DraftValidationService(ctx).ValidateAsync(draftId, CancellationToken.None); errors.ShouldContain(e => e.Code == "BadCrossClusterNamespaceBinding"); } } }