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