diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs index 2c0f4c29..72e711f7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs @@ -1448,6 +1448,9 @@ public sealed class RawTreeService(IDbContextFactory dbF // ScriptId, not Name: Name carries no unique index, and collapsing two same-named scripts into one // would change the count this warning reports. var matched = new Dictionary(StringComparer.Ordinal); + // Sequential batches: a rename at the 50k-distinct-path scale costs ~250 round-trips at 200 terms + // each. Deliberate — a rename is an interactive one-off, and bounded memory + a translatable + // predicate beat one giant OR-chain the provider would refuse (or plan pathologically). for (var offset = 0; offset < distinctPaths.Length; offset += ScriptScanBatchSize) { var batch = distinctPaths[offset..Math.Min(offset + ScriptScanBatchSize, distinctPaths.Length)]; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AdminUiSqlServerFixture.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AdminUiSqlServerFixture.cs new file mode 100644 index 00000000..99390735 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AdminUiSqlServerFixture.cs @@ -0,0 +1,211 @@ +using System.Data.Common; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; + +/// +/// A real SQL Server database for the handful of AdminUI tests whose subject is the SQL Server +/// provider's LINQ translation rather than the C# around it — EF InMemory evaluates those +/// queries in-process, so it can neither fail on an untranslatable shape nor reproduce SQL Server's +/// collation semantics. Creates a uniquely-named database, applies the EF migrations to it, and drops +/// it at collection teardown. +/// +/// +/// +/// Shaped after ZB.MOM.WW.OtOpcUa.Configuration.Tests.SchemaComplianceFixture (same env +/// vars, same unique-name + Migrate() + drop-via-master lifecycle) with one deliberate +/// deviation: that fixture lets the connection failure surface as a constructor throw, so +/// Configuration.Tests fails hard when SQL Server is unreachable. AdminUI.Tests is an ordinary +/// unit suite that must stay green offline, so this fixture probes reachability FIRST and records +/// a instead of throwing. Tests carry , +/// which skips them when the probe failed. +/// +/// +/// The skip is an attribute rather than the Assert.Skip(fixture.SkipReason) call the S7 +/// integration suite uses, because AdminUI.Tests is one of the three projects pinned to +/// xunit v2 by Akka.TestKit.Xunit2 (see Directory.Packages.props) and +/// Assert.Skip is xunit v3 only. Overriding FactAttribute.Skip is the v2 equivalent +/// and reports a genuine "skipped", not a vacuous pass. Both the attribute and this fixture read +/// the same one-shot probe in , so a session probes once. +/// +/// +/// Every test in the collection shares ONE database (migrating is the expensive part), so tests +/// must isolate themselves by seeding uniquely-named rows, or by clearing the table they measure. +/// xunit serialises a collection's tests, so a clear-then-seed is safe. +/// +/// +public sealed class AdminUiSqlServerFixture : IDisposable +{ + /// Gets the name of the per-run test database, or when SQL is unreachable. + public string? DatabaseName { get; } + + /// Gets the connection string for the test database, or when SQL is unreachable. + public string? ConnectionString { get; } + + /// + /// Gets the reason the SQL-backed tests cannot run (server unreachable, migration failed), or + /// when the database is ready. + /// + public string? SkipReason { get; } + + /// + /// Gets a context factory pointed at the migrated test database — the seam the services under test + /// (DeploymentDriftProbe, RawTreeService) take. Throws when + /// is set; guard with . + /// + public IDbContextFactory Factory => + _factory ?? throw new InvalidOperationException(SkipReason ?? "SQL Server fixture is not available."); + + private readonly SqlServerContextFactory? _factory; + + /// Initializes a new instance of the class. + public AdminUiSqlServerFixture() + { + // Probe FIRST — see the remarks. A dead server must leave this constructor without throwing, + // because xunit builds collection fixtures before it discovers that every test is skipped. + SkipReason = AdminUiSqlServer.SkipReason; + if (SkipReason is not null) return; + + DatabaseName = $"OtOpcUaAdminUi_Test_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}"; + ConnectionString = AdminUiSqlServer.ConnectionStringFor(DatabaseName); + + try + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(ConnectionString) + .Options; + + using var ctx = new OtOpcUaConfigDbContext(options); + ctx.Database.Migrate(); + } + catch (Exception ex) + { + // A half-created database would leak, so tear it down before standing the suite down. + TryDropDatabase(); + SkipReason = $"Could not migrate the AdminUI SQL test database: {ex.Message}"; + DatabaseName = null; + ConnectionString = null; + return; + } + + _factory = new SqlServerContextFactory(ConnectionString); + } + + /// Disposes the fixture, dropping the test database. + public void Dispose() => TryDropDatabase(); + + private void TryDropDatabase() + { + if (ConnectionString is null || DatabaseName is null) return; + + try + { + var master = new SqlConnectionStringBuilder(ConnectionString) { InitialCatalog = "master" }.ConnectionString; + + using var conn = new SqlConnection(master); + 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(); + } + catch (DbException) + { + // Teardown is best-effort: a dropped connection here must not fail an otherwise-green run. + // The database name carries a timestamp, so a leaked one is identifiable. + } + } + + private sealed class SqlServerContextFactory(string connectionString) + : IDbContextFactory + { + public OtOpcUaConfigDbContext CreateDbContext() => + new(new DbContextOptionsBuilder() + .UseSqlServer(connectionString) + .Options); + + public Task CreateDbContextAsync(CancellationToken cancellationToken = default) => + Task.FromResult(CreateDbContext()); + } +} + +/// +/// One-shot reachability probe + connection-string builder shared by +/// and . The attribute is +/// constructed at discovery time and the fixture at collection start, so the probe must be cached or +/// a run pays the connect timeout twice. +/// +internal static class AdminUiSqlServer +{ + private const string DefaultServer = "10.100.0.35,14330"; + private const string DefaultSaPassword = "OtOpcUaDev_2026!"; + private const int ProbeTimeoutSeconds = 3; + + private static readonly Lazy Probe = new(RunProbe, LazyThreadSafetyMode.ExecutionAndPublication); + + /// Gets the reason SQL Server is unusable, or when it answered. + public static string? SkipReason => Probe.Value; + + /// Builds a connection string for the named database on the configured test server. + /// The database to connect to. + /// The connection string. + public static string ConnectionStringFor(string database) + { + var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer; + var password = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword; + + return $"Server={server};Database={database};User Id=sa;Password={password};" + + "TrustServerCertificate=True;Encrypt=False;"; + } + + private static string? RunProbe() + { + var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer; + + try + { + var probe = new SqlConnectionStringBuilder(ConnectionStringFor("master")) + { + ConnectTimeout = ProbeTimeoutSeconds, + }.ConnectionString; + + using var conn = new SqlConnection(probe); + conn.Open(); + return null; + } + catch (Exception ex) + { + return $"SQL Server at '{server}' is unreachable ({ex.GetType().Name}: {ex.Message}); " + + "set OTOPCUA_CONFIG_TEST_SERVER / OTOPCUA_CONFIG_TEST_SA_PASSWORD to point at a reachable instance."; + } + } +} + +/// +/// A that skips itself when the shared test SQL Server is unreachable. +/// The xunit v2 equivalent of xunit v3's Assert.Skip — see +/// 's remarks for why this project cannot use the latter. +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class SqlServerFactAttribute : FactAttribute +{ + /// Initializes a new instance of the class. + public SqlServerFactAttribute() + { + if (AdminUiSqlServer.SkipReason is { } reason) Skip = reason; + } +} + +/// Collection definition binding the SQL-backed AdminUI tests to one shared database. +[CollectionDefinition(nameof(AdminUiSqlServerCollection))] +public sealed class AdminUiSqlServerCollection : ICollectionFixture +{ +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/DeploymentDriftProbeSqlServerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/DeploymentDriftProbeSqlServerTests.cs new file mode 100644 index 00000000..50646175 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/DeploymentDriftProbeSqlServerTests.cs @@ -0,0 +1,193 @@ +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Deployments; + +/// +/// The SQL-Server-backed half of : pins that +/// DeploymentDriftProbe's watermark aggregate — ConfigEdits.GroupBy(_ => 1).Select(g => +/// new { g.Max(...), g.Count() }) — actually translates, and translates to the right +/// values, on the real provider. +/// +/// +/// +/// EF InMemory evaluates that query with LINQ-to-Objects, so the InMemory suite proves the C# +/// and nothing about the SQL. Here an untranslatable shape throws and fails the test outright, +/// and a mistranslation is caught by moving exactly one half of the aggregate at a time: +/// +/// +/// a later-timestamped edit moves Max only (its Count also moves, so this +/// one alone is not decisive) — +/// an earlier-timestamped edit leaves Max where it was and moves +/// Count alone, which is the only thing that can invalidate the cache; a translation +/// that dropped the Count projection would serve the stale verdict and fail here. +/// +/// +/// The watermark itself is private, so the assertions read it through its one observable +/// consequence: whether the probe recomposes. That is deliberate — asserting a copy of the query +/// would pin a copy, not the production one. +/// +/// +/// The table is cleared at the start of every test: the collection shares one database and the +/// watermark is a whole-table aggregate. +/// +/// +[Collection(nameof(AdminUiSqlServerCollection))] +public sealed class DeploymentDriftProbeSqlServerTests(AdminUiSqlServerFixture sql) +{ + private const string SealedHash = "sealed-hash-aaaa"; + + /// A fixed instant so the "earlier edit" case is unambiguously earlier, clock skew aside. + private static readonly DateTime Anchor = new(2026, 8, 17, 12, 0, 0, DateTimeKind.Utc); + + [SqlServerFact] + public async Task An_empty_ConfigEdits_table_yields_a_verdict_and_caches_it() + { + // The no-rows shape is the one GroupBy(_ => 1) makes subtle: SQL Server produces NO group at all, + // so FirstOrDefaultAsync must land on null rather than on a row of (NULL, 0). + await ClearConfigEditsAsync(); + var compose = new CountingCompose(SealedHash); + var probe = new DeploymentDriftProbe(sql.Factory, compose.ComposeAsync); + + var first = await probe.GetDriftAsync(SealedHash, CancellationToken.None); + var second = await probe.GetDriftAsync(SealedHash, CancellationToken.None); + + first.ShouldBe(false); + second.ShouldBe(false); + compose.Count.ShouldBe(1); + } + + [SqlServerFact] + public async Task An_unchanged_ConfigEdits_table_reuses_the_cached_verdict() + { + await ClearConfigEditsAsync(); + await AddEditsAsync(Anchor, Anchor.AddSeconds(1)); + var compose = new CountingCompose(SealedHash); + var probe = new DeploymentDriftProbe(sql.Factory, compose.ComposeAsync); + + await probe.GetDriftAsync(SealedHash, CancellationToken.None); + var second = await probe.GetDriftAsync(SealedHash, CancellationToken.None); + + // Two reads of the same rows must produce an EQUAL watermark. Not a tautology against SQL: + // EditedAtUtc round-trips through datetime2 and an unstable projection would recompose forever. + second.ShouldBe(false); + compose.Count.ShouldBe(1); + } + + [SqlServerFact] + public async Task A_later_edit_moves_the_watermark_and_recomposes() + { + await ClearConfigEditsAsync(); + await AddEditsAsync(Anchor); + var compose = new CountingCompose(SealedHash); + var probe = new DeploymentDriftProbe(sql.Factory, compose.ComposeAsync); + + (await probe.GetDriftAsync(SealedHash, CancellationToken.None)).ShouldBe(false); + + await AddEditsAsync(Anchor.AddHours(1)); + compose.Result = "a-different-hash"; + + (await probe.GetDriftAsync(SealedHash, CancellationToken.None)).ShouldBe(true); + compose.Count.ShouldBe(2); + } + + [SqlServerFact] + public async Task An_earlier_edit_moves_only_the_count_and_still_recomposes() + { + // The decisive case for the Count half: MAX(EditedAtUtc) is unchanged, so a translation that lost + // the COUNT(*) projection would report the same watermark and serve the stale (false) verdict. + await ClearConfigEditsAsync(); + await AddEditsAsync(Anchor); + var compose = new CountingCompose(SealedHash); + var probe = new DeploymentDriftProbe(sql.Factory, compose.ComposeAsync); + + (await probe.GetDriftAsync(SealedHash, CancellationToken.None)).ShouldBe(false); + + await AddEditsAsync(Anchor.AddHours(-1)); + compose.Result = "a-different-hash"; + + (await probe.GetDriftAsync(SealedHash, CancellationToken.None)).ShouldBe(true); + compose.Count.ShouldBe(2); + } + + [SqlServerFact] + public async Task A_same_timestamp_edit_moves_only_the_count_and_still_recomposes() + { + // Two mutations inside one tick — the case the CLAUDE.md/probe remarks call out explicitly. + await ClearConfigEditsAsync(); + await AddEditsAsync(Anchor); + var compose = new CountingCompose(SealedHash); + var probe = new DeploymentDriftProbe(sql.Factory, compose.ComposeAsync); + + (await probe.GetDriftAsync(SealedHash, CancellationToken.None)).ShouldBe(false); + + await AddEditsAsync(Anchor); + compose.Result = "a-different-hash"; + + (await probe.GetDriftAsync(SealedHash, CancellationToken.None)).ShouldBe(true); + compose.Count.ShouldBe(2); + } + + [SqlServerFact] + public async Task Emptying_the_table_after_a_verdict_recomposes() + { + // Exercises the populated-then-empty transition, i.e. the aggregate crossing back to "no group". + await ClearConfigEditsAsync(); + await AddEditsAsync(Anchor, Anchor.AddSeconds(1)); + var compose = new CountingCompose(SealedHash); + var probe = new DeploymentDriftProbe(sql.Factory, compose.ComposeAsync); + + (await probe.GetDriftAsync(SealedHash, CancellationToken.None)).ShouldBe(false); + + await ClearConfigEditsAsync(); + compose.Result = "a-different-hash"; + + (await probe.GetDriftAsync(SealedHash, CancellationToken.None)).ShouldBe(true); + compose.Count.ShouldBe(2); + } + + // -------------------------------------------------------------------------------------- Helpers + + private async Task ClearConfigEditsAsync() + { + await using var db = await sql.Factory.CreateDbContextAsync(); + await db.ConfigEdits.ExecuteDeleteAsync(); + } + + private async Task AddEditsAsync(params DateTime[] editedAtUtc) + { + await using var db = await sql.Factory.CreateDbContextAsync(); + foreach (var at in editedAtUtc) + { + db.ConfigEdits.Add(new ConfigEdit + { + EntityType = "Equipment", + EntityId = Guid.NewGuid(), + FieldsJson = "{}", + EditedBy = "test", + SourceNode = "node-1", + EditedAtUtc = at, + }); + } + + await db.SaveChangesAsync(); + } + + /// Stand-in for the ConfigComposer seam that counts how often it was invoked. + private sealed class CountingCompose(string result) + { + public string Result { get; set; } = result; + + public int Count { get; private set; } + + public Task ComposeAsync(OtOpcUaConfigDbContext db) + { + Count++; + return Task.FromResult(Result); + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceRenameWarningSqlServerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceRenameWarningSqlServerTests.cs new file mode 100644 index 00000000..4fb1090f --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceRenameWarningSqlServerTests.cs @@ -0,0 +1,222 @@ +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +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.AdminUI.Tests.Uns; + +/// +/// The SQL-Server-backed half of : pins that the +/// expression-built SourceCode.Contains(p0) || … || Contains(pN) disjunction +/// (RawTreeService.AnyPathContained) translates on the real provider, and pins the one +/// behaviour EF InMemory structurally cannot show — the collation widening the method's +/// remarks describe. +/// +/// +/// +/// Under EF InMemory the predicate runs as ordinal , so a +/// script naming a RawPath in different casing does NOT match there. On SQL Server the comparison +/// runs under the column collation — case-insensitive on a default install — and it DOES. That is +/// a deliberate, documented widening (a false positive costs an operator one glance; a false +/// negative ships a broken rename), and it is only assertable against a real server. +/// +/// +/// Also crosses the 200-term ScriptScanBatchSize chunk boundary against the real provider, +/// where a batch too large would be a translation/complexity failure rather than a silent +/// in-memory loop. +/// +/// +/// Every test seeds its own cluster/folder/driver subtree under a unique token, so the seeds do +/// not collide and — since the script scan is a whole-table substring search — no test's paths +/// can be matched by another's scripts. +/// +/// +[Collection(nameof(AdminUiSqlServerCollection))] +public sealed class RawTreeServiceRenameWarningSqlServerTests(AdminUiSqlServerFixture sql) +{ + /// Mirrors RawTreeService.ScriptScanBatchSize (private); the boundary under test. + private const int ScriptScanBatchSize = 200; + + [SqlServerFact] + public async Task A_script_naming_the_path_in_different_case_is_matched_under_the_server_collation() + { + var seed = await SeedAsync(token => new Seed( + Tags: ["flow"], + Scripts: + [ + // Same path, opposite casing throughout. Ordinal Contains (EF InMemory) misses this; SQL + // Server's default CI collation finds it. + new ScriptSeed("CaseScript", $"var v = ctx.GetTag(\"{RawPath(token, "flow").ToUpperInvariant()}\");"), + ])); + + var result = await seed.Service.RenameDeviceAsync(seed.DeviceId, "Dev-renamed", seed.DeviceRowVersion); + + result.Ok.ShouldBeTrue(result.Error); + result.Warnings.ShouldContain(w => w.Contains("script") && w.Contains("CaseScript")); + } + + [SqlServerFact] + public async Task A_script_naming_the_path_in_the_same_case_is_matched() + { + // Control for the case-insensitivity test above: if this one failed too, the previous test would + // be proving the scan runs at all rather than proving the widening. + var seed = await SeedAsync(token => new Seed( + Tags: ["flow"], + Scripts: [new ScriptSeed("ExactScript", $"var v = ctx.GetTag(\"{RawPath(token, "flow")}\");")])); + + var result = await seed.Service.RenameDeviceAsync(seed.DeviceId, "Dev-renamed", seed.DeviceRowVersion); + + result.Ok.ShouldBeTrue(result.Error); + result.Warnings.ShouldContain(w => w.Contains("script") && w.Contains("ExactScript")); + } + + [SqlServerFact] + public async Task A_script_naming_no_affected_path_is_not_matched() + { + // Falsification control: the disjunction must be a real filter server-side, not a predicate that + // degenerates to "true" (which every match assertion above would still pass against). + var seed = await SeedAsync(token => new Seed( + Tags: ["flow"], + Scripts: [new ScriptSeed("UnrelatedScript", $"var v = ctx.GetTag(\"{RawPath(token, "not-a-tag-here")}\");")])); + + var result = await seed.Service.RenameDeviceAsync(seed.DeviceId, "Dev-renamed", seed.DeviceRowVersion); + + result.Ok.ShouldBeTrue(result.Error); + result.Warnings.ShouldNotContain(w => w.Contains("script")); + } + + [SqlServerFact] + public async Task Matches_are_found_on_both_sides_of_the_scan_batch_boundary() + { + // 201 distinct paths ⇒ two batches at the 200-term cap. One script names a path in the first + // batch, one names the single path that lands in the second: a loop that dropped either batch, or + // a 200-term OR-chain the provider refused to translate, fails here. + const int tagCount = ScriptScanBatchSize + 1; + var names = Enumerable.Range(0, tagCount).Select(i => $"bulk-{i:D3}").ToArray(); + + var seed = await SeedAsync(token => new Seed( + Tags: names, + Scripts: + [ + new ScriptSeed("BatchFirst", $"ctx.GetTag(\"{RawPath(token, names[0])}\");"), + new ScriptSeed("BatchLast", $"ctx.GetTag(\"{RawPath(token, names[^1])}\");"), + ])); + + var result = await seed.Service.RenameDeviceAsync(seed.DeviceId, "Dev-renamed", seed.DeviceRowVersion); + + result.Ok.ShouldBeTrue(result.Error); + result.Warnings.ShouldContain( + "2 scripts ('BatchFirst', 'BatchLast') reference tags beneath this node by their raw paths; " + + "the references will break unless the scripts are updated."); + } + + // ----------------------------------------------------------------------------------------- Seed + + private const string FolderName = "Plant"; + private const string DriverName = "modbus-1"; + private const string DeviceName = "Dev-scan"; + + /// The RawPath a seeded tag resolves to: Folder/Driver/Device/Tag (the cluster is not in it). + private static string RawPath(string token, string tagName) => + $"{FolderName}{token}/{DriverName}/{DeviceName}/{tagName}"; + + private sealed record ScriptSeed(string Name, string SourceCode); + + private sealed record Seed(IReadOnlyList Tags, IReadOnlyList Scripts); + + private sealed record SeedResult(RawTreeService Service, string DeviceId, byte[] DeviceRowVersion); + + private async Task SeedAsync(Func build) + { + // 8 hex chars: unique per test, and short enough for the 64-char id columns. + var token = Guid.NewGuid().ToString("N")[..8]; + var seed = build(token); + + var clusterId = $"C-{token}"; + var folderId = $"RF-{token}"; + var driverId = $"DRV-{token}"; + var deviceId = $"DEV-{token}"; + + await using (var db = await sql.Factory.CreateDbContextAsync()) + { + db.ServerClusters.Add(new ServerCluster + { + ClusterId = clusterId, + Name = $"Cluster {token}", + Enterprise = "zb", + Site = "warsaw-west", + // NodeCount is NOT optional here as it is under EF InMemory: CK_ServerCluster_ + // RedundancyMode_NodeCount pins None ⇒ 1 (and Warm/Hot ⇒ 2). + NodeCount = 1, + RedundancyMode = RedundancyMode.None, + CreatedBy = "test", + }); + db.RawFolders.Add(new RawFolder + { + RawFolderId = folderId, + ClusterId = clusterId, + ParentRawFolderId = null, + Name = $"{FolderName}{token}", + }); + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = driverId, + ClusterId = clusterId, + RawFolderId = folderId, + Name = DriverName, + DriverType = "Modbus", + DriverConfig = "{}", + }); + db.Devices.Add(new Device + { + DeviceId = deviceId, + DriverInstanceId = driverId, + Name = DeviceName, + DeviceConfig = "{}", + }); + + for (var i = 0; i < seed.Tags.Count; i++) + { + db.Tags.Add(new Tag + { + TagId = $"TAG-{token}-{i:D4}", + DeviceId = deviceId, + TagGroupId = null, + Name = seed.Tags[i], + DataType = "Float", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{}", + }); + } + + for (var i = 0; i < seed.Scripts.Count; i++) + { + db.Scripts.Add(new Script + { + ScriptId = $"SC-{token}-{i:D2}", + Name = seed.Scripts[i].Name, + SourceCode = seed.Scripts[i].SourceCode, + SourceHash = $"hash-{token}-{i:D2}", + }); + } + + await db.SaveChangesAsync(); + } + + // SQL Server DOES enforce the rowversion token (EF InMemory does not), so the rename must carry + // the value the server actually stamped. + byte[] rowVersion; + await using (var db = await sql.Factory.CreateDbContextAsync()) + { + rowVersion = await db.Devices.AsNoTracking() + .Where(d => d.DeviceId == deviceId) + .Select(d => d.RowVersion) + .SingleAsync(); + } + + return new SeedResult(new RawTreeService(sql.Factory), deviceId, rowVersion); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj index 88277f85..ba5aba32 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj @@ -11,6 +11,11 @@ + +