Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceRenameWarningSqlServerTests.cs
T

223 lines
9.5 KiB
C#

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;
/// <summary>
/// The SQL-Server-backed half of <see cref="RawTreeServiceRenameWarningTests"/>: pins that the
/// expression-built <c>SourceCode.Contains(p0) || … || Contains(pN)</c> disjunction
/// (<c>RawTreeService.AnyPathContained</c>) <b>translates</b> on the real provider, and pins the one
/// behaviour EF InMemory structurally cannot show — the <b>collation widening</b> the method's
/// remarks describe.
/// </summary>
/// <remarks>
/// <para>
/// Under EF InMemory the predicate runs as ordinal <see cref="string.Contains(string)"/>, 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.
/// </para>
/// <para>
/// Also crosses the 200-term <c>ScriptScanBatchSize</c> chunk boundary against the real provider,
/// where a batch too large would be a translation/complexity failure rather than a silent
/// in-memory loop.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[Collection(nameof(AdminUiSqlServerCollection))]
public sealed class RawTreeServiceRenameWarningSqlServerTests(AdminUiSqlServerFixture sql)
{
/// <summary>Mirrors <c>RawTreeService.ScriptScanBatchSize</c> (private); the boundary under test.</summary>
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";
/// <summary>The RawPath a seeded tag resolves to: Folder/Driver/Device/Tag (the cluster is not in it).</summary>
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<string> Tags, IReadOnlyList<ScriptSeed> Scripts);
private sealed record SeedResult(RawTreeService Service, string DeviceId, byte[] DeviceRowVersion);
private async Task<SeedResult> SeedAsync(Func<string, Seed> 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);
}
}