chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Creates two throwaway DB users — one in <c>OtOpcUaNode</c>, one in <c>OtOpcUaAdmin</c> —
|
||||
/// and verifies the grants/denies from the <c>AuthorizationGrants</c> migration.
|
||||
/// </summary>
|
||||
[Trait("Category", "Authorization")]
|
||||
[Collection(nameof(SchemaComplianceCollection))]
|
||||
public sealed class AuthorizationTests
|
||||
{
|
||||
private readonly SchemaComplianceFixture _fixture;
|
||||
|
||||
public AuthorizationTests(SchemaComplianceFixture fixture) => _fixture = fixture;
|
||||
|
||||
[Fact]
|
||||
public void Node_role_can_execute_GetCurrentGenerationForCluster_but_not_PublishGeneration()
|
||||
{
|
||||
var (user, password) = CreateUserInRole(_fixture, "Node");
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = OpenAs(user, password);
|
||||
|
||||
Should.Throw<SqlException>(() =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "EXEC dbo.sp_PublishGeneration @ClusterId='x', @DraftGenerationId=1";
|
||||
cmd.ExecuteNonQuery();
|
||||
}).Message.ShouldContain("permission", Case.Insensitive);
|
||||
|
||||
// Calling a granted proc authenticates; the proc itself will RAISERROR with Unauthorized
|
||||
// because our test principal isn't bound to any node — that's expected.
|
||||
var ex = Should.Throw<SqlException>(() =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "EXEC dbo.sp_GetCurrentGenerationForCluster @NodeId='n', @ClusterId='c'";
|
||||
cmd.ExecuteNonQuery();
|
||||
});
|
||||
ex.Message.ShouldContain("Unauthorized");
|
||||
}
|
||||
finally
|
||||
{
|
||||
DropUser(_fixture, user);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Node_role_cannot_SELECT_from_tables_directly()
|
||||
{
|
||||
var (user, password) = CreateUserInRole(_fixture, "Node");
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = OpenAs(user, password);
|
||||
var ex = Should.Throw<SqlException>(() =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM dbo.ConfigGeneration";
|
||||
cmd.ExecuteScalar();
|
||||
});
|
||||
ex.Message.ShouldContain("permission", Case.Insensitive);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DropUser(_fixture, user);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Admin_role_can_execute_PublishGeneration()
|
||||
{
|
||||
var (user, password) = CreateUserInRole(_fixture, "Admin");
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = OpenAs(user, password);
|
||||
// Calling the proc is permitted; content-level errors (missing draft) are OK — they
|
||||
// prove the grant succeeded (we got past the permission check into the proc body).
|
||||
var ex = Should.Throw<SqlException>(() =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "EXEC dbo.sp_PublishGeneration @ClusterId='no-such-cluster', @DraftGenerationId=9999";
|
||||
cmd.ExecuteNonQuery();
|
||||
});
|
||||
ex.Message.ShouldNotContain("permission", Case.Insensitive);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DropUser(_fixture, user);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Creates a SQL login + DB user in the given role and returns its credentials.</summary>
|
||||
private static (string User, string Password) CreateUserInRole(SchemaComplianceFixture fx, string role)
|
||||
{
|
||||
var user = $"tst_{role.ToLower()}_{Guid.NewGuid():N}"[..24];
|
||||
const string password = "TestUser_2026!";
|
||||
var dbRole = role == "Node" ? "OtOpcUaNode" : "OtOpcUaAdmin";
|
||||
|
||||
// Create the login in master, the user in the test DB, and add it to the role.
|
||||
using (var conn = new SqlConnection(
|
||||
new SqlConnectionStringBuilder(fx.ConnectionString) { InitialCatalog = "master" }.ConnectionString))
|
||||
{
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"CREATE LOGIN [{user}] WITH PASSWORD = '{password}', CHECK_POLICY = OFF;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var conn = fx.OpenConnection())
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $@"
|
||||
CREATE USER [{user}] FOR LOGIN [{user}];
|
||||
ALTER ROLE {dbRole} ADD MEMBER [{user}];";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
return (user, password);
|
||||
}
|
||||
|
||||
private static void DropUser(SchemaComplianceFixture fx, string user)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var dbConn = fx.OpenConnection();
|
||||
using var cmd1 = dbConn.CreateCommand();
|
||||
cmd1.CommandText = $"IF DATABASE_PRINCIPAL_ID('{user}') IS NOT NULL DROP USER [{user}];";
|
||||
cmd1.ExecuteNonQuery();
|
||||
}
|
||||
catch { /* swallow — fixture disposes the DB anyway */ }
|
||||
|
||||
try
|
||||
{
|
||||
using var master = new SqlConnection(
|
||||
new SqlConnectionStringBuilder(fx.ConnectionString) { InitialCatalog = "master" }.ConnectionString);
|
||||
master.Open();
|
||||
using var cmd = master.CreateCommand();
|
||||
cmd.CommandText = $"IF SUSER_ID('{user}') IS NOT NULL DROP LOGIN [{user}];";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
private SqlConnection OpenAs(string user, string password)
|
||||
{
|
||||
var cs = new SqlConnectionStringBuilder(_fixture.ConnectionString)
|
||||
{
|
||||
UserID = user,
|
||||
Password = password,
|
||||
IntegratedSecurity = false,
|
||||
}.ConnectionString;
|
||||
|
||||
var conn = new SqlConnection(cs);
|
||||
conn.Open();
|
||||
return conn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DraftValidatorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("valid-name", true)]
|
||||
[InlineData("line-01", true)]
|
||||
[InlineData("_default", true)]
|
||||
[InlineData("UPPER", false)]
|
||||
[InlineData("with space", false)]
|
||||
[InlineData("", false)]
|
||||
public void UnsSegment_rule_accepts_lowercase_or_default_only(string name, bool shouldPass)
|
||||
{
|
||||
var uuid = Guid.NewGuid();
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Equipment =
|
||||
[
|
||||
new Equipment
|
||||
{
|
||||
EquipmentUuid = uuid,
|
||||
EquipmentId = DraftValidator.DeriveEquipmentId(uuid),
|
||||
Name = name,
|
||||
DriverInstanceId = "d",
|
||||
UnsLineId = "line-a",
|
||||
MachineCode = "m",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var errors = DraftValidator.Validate(draft);
|
||||
var hasUnsError = errors.Any(e => e.Code == "UnsSegmentInvalid");
|
||||
hasUnsError.ShouldBe(!shouldPass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cross_cluster_namespace_binding_is_rejected()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c-A",
|
||||
Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c-B", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }],
|
||||
DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c-A", NamespaceId = "ns-1", Name = "drv", DriverType = "ModbusTcp", DriverConfig = "{}" }],
|
||||
};
|
||||
|
||||
var errors = DraftValidator.Validate(draft);
|
||||
errors.ShouldContain(e => e.Code == "BadCrossClusterNamespaceBinding");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Same_cluster_namespace_binding_is_accepted()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c-A",
|
||||
Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c-A", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }],
|
||||
DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c-A", NamespaceId = "ns-1", Name = "drv", DriverType = "ModbusTcp", DriverConfig = "{}" }],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "BadCrossClusterNamespaceBinding");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EquipmentUuid_change_across_generations_is_rejected()
|
||||
{
|
||||
var oldUuid = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
var newUuid = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||
var eid = DraftValidator.DeriveEquipmentId(oldUuid);
|
||||
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 2, ClusterId = "c",
|
||||
Equipment = [new Equipment { EquipmentUuid = newUuid, EquipmentId = eid, Name = "eq", DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m" }],
|
||||
PriorEquipment = [new Equipment { EquipmentUuid = oldUuid, EquipmentId = eid, Name = "eq", DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m" }],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentUuidImmutable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZTag_reserved_by_different_uuid_is_rejected()
|
||||
{
|
||||
var uuid = Guid.NewGuid();
|
||||
var otherUuid = Guid.NewGuid();
|
||||
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = DraftValidator.DeriveEquipmentId(uuid), Name = "eq", DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m", ZTag = "ZT-001" }],
|
||||
ActiveReservations = [new ExternalIdReservation { Kind = ReservationKind.ZTag, Value = "ZT-001", EquipmentUuid = otherUuid, ClusterId = "c", FirstPublishedBy = "t" }],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "BadDuplicateExternalIdentifier");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EquipmentId_that_does_not_match_derivation_is_rejected()
|
||||
{
|
||||
var uuid = Guid.NewGuid();
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = "EQ-operator-typed", Name = "eq", DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m" }],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentIdNotDerived");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Galaxy_driver_in_Equipment_namespace_is_rejected()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }],
|
||||
DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c", NamespaceId = "ns-1", Name = "drv", DriverType = "Galaxy", DriverConfig = "{}" }],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "DriverNamespaceKindMismatch");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Draft_with_three_violations_surfaces_all_three()
|
||||
{
|
||||
var uuid = Guid.NewGuid();
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c-A",
|
||||
Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c-B", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }],
|
||||
DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c-A", NamespaceId = "ns-1", Name = "drv", DriverType = "Galaxy", DriverConfig = "{}" }],
|
||||
Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = "EQ-wrong", Name = "BAD NAME", DriverInstanceId = "d-1", UnsLineId = "line-a", MachineCode = "m" }],
|
||||
};
|
||||
|
||||
var errors = DraftValidator.Validate(draft);
|
||||
errors.ShouldContain(e => e.Code == "BadCrossClusterNamespaceBinding");
|
||||
errors.ShouldContain(e => e.Code == "DriverNamespaceKindMismatch");
|
||||
errors.ShouldContain(e => e.Code == "EquipmentIdNotDerived");
|
||||
errors.ShouldContain(e => e.Code == "UnsSegmentInvalid");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
// Phase 6.3 task #148 part 2 — ValidateClusterTopology
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, RedundancyMode.None, 1, 0)] // single-node standalone — ok
|
||||
[InlineData(2, RedundancyMode.Warm, 2, 0)] // 2-node warm — ok
|
||||
[InlineData(2, RedundancyMode.Hot, 2, 0)] // 2-node hot — ok
|
||||
[InlineData(1, RedundancyMode.Warm, 1, 1)] // declared mismatch — should flag
|
||||
[InlineData(2, RedundancyMode.None, 2, 1)] // None with 2 nodes — should flag
|
||||
public void ValidateClusterTopology_checks_declared_pair(
|
||||
byte nodeCount, RedundancyMode mode, int enabledNodes, int expectedDeclaredErrors)
|
||||
{
|
||||
var cluster = BuildCluster(nodeCount: nodeCount, mode: mode);
|
||||
var nodes = Enumerable.Range(0, enabledNodes)
|
||||
.Select(i => BuildNode($"n-{i}", enabled: true, role: i == 0 ? RedundancyRole.Primary : RedundancyRole.Secondary))
|
||||
.ToList();
|
||||
|
||||
var errors = DraftValidator.ValidateClusterTopology(cluster, nodes);
|
||||
errors.Count(e => e.Code == "ClusterRedundancyModeInvalid").ShouldBe(expectedDeclaredErrors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateClusterTopology_flags_disabled_node_mismatch()
|
||||
{
|
||||
// Declared 2 + Hot, but one node disabled — runtime would boot InvalidTopology.
|
||||
var cluster = BuildCluster(nodeCount: 2, mode: RedundancyMode.Hot);
|
||||
var nodes = new[]
|
||||
{
|
||||
BuildNode("primary", enabled: true, role: RedundancyRole.Primary),
|
||||
BuildNode("backup", enabled: false, role: RedundancyRole.Secondary),
|
||||
};
|
||||
|
||||
var errors = DraftValidator.ValidateClusterTopology(cluster, nodes);
|
||||
errors.ShouldContain(e => e.Code == "ClusterEnabledNodeCountMismatch");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateClusterTopology_flags_multiple_Primary()
|
||||
{
|
||||
var cluster = BuildCluster(nodeCount: 2, mode: RedundancyMode.Hot);
|
||||
var nodes = new[]
|
||||
{
|
||||
BuildNode("a", enabled: true, role: RedundancyRole.Primary),
|
||||
BuildNode("b", enabled: true, role: RedundancyRole.Primary),
|
||||
};
|
||||
|
||||
var errors = DraftValidator.ValidateClusterTopology(cluster, nodes);
|
||||
errors.ShouldContain(e => e.Code == "ClusterMultiplePrimary");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateClusterTopology_returns_no_errors_on_valid_standalone()
|
||||
{
|
||||
var cluster = BuildCluster(nodeCount: 1, mode: RedundancyMode.None);
|
||||
var nodes = new[] { BuildNode("only", enabled: true, role: RedundancyRole.Primary) };
|
||||
|
||||
var errors = DraftValidator.ValidateClusterTopology(cluster, nodes);
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private static ServerCluster BuildCluster(byte nodeCount, RedundancyMode mode) => new()
|
||||
{
|
||||
ClusterId = "c-test",
|
||||
Name = "Test",
|
||||
Enterprise = "zb",
|
||||
Site = "dev",
|
||||
NodeCount = nodeCount,
|
||||
RedundancyMode = mode,
|
||||
Enabled = true,
|
||||
CreatedBy = "t",
|
||||
};
|
||||
|
||||
private static ClusterNode BuildNode(string id, bool enabled, RedundancyRole role) => new()
|
||||
{
|
||||
NodeId = id,
|
||||
ClusterId = "c-test",
|
||||
RedundancyRole = role,
|
||||
Host = "localhost",
|
||||
OpcUaPort = 4840,
|
||||
DashboardPort = 5001,
|
||||
ApplicationUri = $"urn:{id}",
|
||||
ServiceLevelBase = 200,
|
||||
Enabled = enabled,
|
||||
CreatedBy = "t",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end round-trip through the DB for the <see cref="DriverHostStatus"/> entity
|
||||
/// added in PR 33 — exercises the composite primary key (NodeId, DriverInstanceId,
|
||||
/// HostName), string-backed <c>DriverHostState</c> conversion, and the two indexes the
|
||||
/// Admin UI's drill-down queries will scan (NodeId, LastSeenUtc).
|
||||
/// </summary>
|
||||
[Trait("Category", "SchemaCompliance")]
|
||||
[Collection(nameof(SchemaComplianceCollection))]
|
||||
public sealed class DriverHostStatusTests(SchemaComplianceFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Composite_key_allows_same_host_across_different_nodes_or_drivers()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
|
||||
// Same HostName + DriverInstanceId across two different server nodes — classic 2-node
|
||||
// redundancy case. Both rows must be insertable because each server node owns its own
|
||||
// runtime view of the shared host.
|
||||
var now = DateTime.UtcNow;
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "node-a", DriverInstanceId = "galaxy-1", HostName = "GRPlatform",
|
||||
State = DriverHostState.Running,
|
||||
StateChangedUtc = now, LastSeenUtc = now,
|
||||
});
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "node-b", DriverInstanceId = "galaxy-1", HostName = "GRPlatform",
|
||||
State = DriverHostState.Stopped,
|
||||
StateChangedUtc = now, LastSeenUtc = now,
|
||||
Detail = "secondary hasn't taken over yet",
|
||||
});
|
||||
// Same server node + host, different driver instance — second driver doesn't clobber.
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "node-a", DriverInstanceId = "modbus-plc1", HostName = "GRPlatform",
|
||||
State = DriverHostState.Running,
|
||||
StateChangedUtc = now, LastSeenUtc = now,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var rows = await ctx.DriverHostStatuses.AsNoTracking()
|
||||
.Where(r => r.HostName == "GRPlatform").ToListAsync();
|
||||
|
||||
rows.Count.ShouldBe(3);
|
||||
rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "galaxy-1");
|
||||
rows.ShouldContain(r => r.NodeId == "node-b" && r.State == DriverHostState.Stopped && r.Detail == "secondary hasn't taken over yet");
|
||||
rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "modbus-plc1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_pattern_for_same_key_updates_in_place()
|
||||
{
|
||||
// The publisher hosted service (follow-up PR) upserts on every transition +
|
||||
// heartbeat. This test pins the two-step pattern it will use: check-then-add-or-update
|
||||
// keyed on the composite PK. If the composite key ever changes, this test breaks
|
||||
// loudly so the publisher gets a synchronized update.
|
||||
await using var ctx = NewContext();
|
||||
var t0 = DateTime.UtcNow;
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "upsert-node", DriverInstanceId = "upsert-driver", HostName = "upsert-host",
|
||||
State = DriverHostState.Running,
|
||||
StateChangedUtc = t0, LastSeenUtc = t0,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var t1 = t0.AddSeconds(30);
|
||||
await using (var ctx2 = NewContext())
|
||||
{
|
||||
var existing = await ctx2.DriverHostStatuses.SingleAsync(r =>
|
||||
r.NodeId == "upsert-node" && r.DriverInstanceId == "upsert-driver" && r.HostName == "upsert-host");
|
||||
existing.State = DriverHostState.Faulted;
|
||||
existing.StateChangedUtc = t1;
|
||||
existing.LastSeenUtc = t1;
|
||||
existing.Detail = "transport reset by peer";
|
||||
await ctx2.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using var ctx3 = NewContext();
|
||||
var final = await ctx3.DriverHostStatuses.AsNoTracking().SingleAsync(r =>
|
||||
r.NodeId == "upsert-node" && r.HostName == "upsert-host");
|
||||
final.State.ShouldBe(DriverHostState.Faulted);
|
||||
final.Detail.ShouldBe("transport reset by peer");
|
||||
// Only one row — a naive "always insert" would have created a duplicate PK and thrown.
|
||||
(await ctx3.DriverHostStatuses.CountAsync(r => r.NodeId == "upsert-node")).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enum_persists_as_string_not_int()
|
||||
{
|
||||
// Fluent config sets HasConversion<string>() on State — the DB stores 'Running' /
|
||||
// 'Stopped' / 'Faulted' / 'Unknown' as nvarchar(16). Verify by reading the raw
|
||||
// string back via ADO; if someone drops the conversion the column will contain '1'
|
||||
// / '2' / '3' and this assertion fails. Matters because DBAs inspecting the table
|
||||
// directly should see readable state names, not enum ordinals.
|
||||
await using var ctx = NewContext();
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "enum-node", DriverInstanceId = "enum-driver", HostName = "enum-host",
|
||||
State = DriverHostState.Faulted,
|
||||
StateChangedUtc = DateTime.UtcNow, LastSeenUtc = DateTime.UtcNow,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
await using var conn = fixture.OpenConnection();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT [State] FROM DriverHostStatus WHERE NodeId = 'enum-node'";
|
||||
var rawValue = (string?)await cmd.ExecuteScalarAsync();
|
||||
rawValue.ShouldBe("Faulted");
|
||||
}
|
||||
|
||||
private OtOpcUaConfigDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseSqlServer(fixture.ConnectionString)
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class GenerationApplierTests
|
||||
{
|
||||
private static DraftSnapshot SnapshotWith(
|
||||
IReadOnlyList<DriverInstance>? drivers = null,
|
||||
IReadOnlyList<Equipment>? equipment = null,
|
||||
IReadOnlyList<Tag>? tags = null) => new()
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
DriverInstances = drivers ?? [],
|
||||
Equipment = equipment ?? [],
|
||||
Tags = tags ?? [],
|
||||
};
|
||||
|
||||
private static DriverInstance Driver(string id) =>
|
||||
new() { DriverInstanceId = id, ClusterId = "c", NamespaceId = "ns", Name = id, DriverType = "ModbusTcp", DriverConfig = "{}" };
|
||||
|
||||
private static Equipment Eq(string id, Guid uuid) =>
|
||||
new() { EquipmentUuid = uuid, EquipmentId = id, DriverInstanceId = "d", UnsLineId = "line-a", Name = id, MachineCode = id };
|
||||
|
||||
private static Tag Tag(string id, string name) =>
|
||||
new() { TagId = id, DriverInstanceId = "d", Name = name, FolderPath = "/a", DataType = "Int32", AccessLevel = TagAccessLevel.Read, TagConfig = "{}" };
|
||||
|
||||
[Fact]
|
||||
public void Diff_from_empty_to_one_driver_five_equipment_fifty_tags_is_all_Added()
|
||||
{
|
||||
var uuid = (int i) => Guid.Parse($"00000000-0000-0000-0000-{i:000000000000}");
|
||||
var equipment = Enumerable.Range(1, 5).Select(i => Eq($"eq-{i}", uuid(i))).ToList();
|
||||
var tags = Enumerable.Range(1, 50).Select(i => Tag($"tag-{i}", $"T{i}")).ToList();
|
||||
|
||||
var diff = GenerationDiffer.Compute(from: null,
|
||||
to: SnapshotWith(drivers: [Driver("d-1")], equipment: equipment, tags: tags));
|
||||
|
||||
diff.Drivers.Count.ShouldBe(1);
|
||||
diff.Drivers.ShouldAllBe(c => c.Kind == ChangeKind.Added);
|
||||
diff.Equipment.Count.ShouldBe(5);
|
||||
diff.Equipment.ShouldAllBe(c => c.Kind == ChangeKind.Added);
|
||||
diff.Tags.Count.ShouldBe(50);
|
||||
diff.Tags.ShouldAllBe(c => c.Kind == ChangeKind.Added);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diff_flags_single_tag_name_change_as_Modified_only_for_that_tag()
|
||||
{
|
||||
var before = SnapshotWith(tags: [Tag("tag-1", "Old"), Tag("tag-2", "Keep")]);
|
||||
var after = SnapshotWith(tags: [Tag("tag-1", "New"), Tag("tag-2", "Keep")]);
|
||||
|
||||
var diff = GenerationDiffer.Compute(before, after);
|
||||
|
||||
diff.Tags.Count.ShouldBe(1);
|
||||
diff.Tags[0].Kind.ShouldBe(ChangeKind.Modified);
|
||||
diff.Tags[0].LogicalId.ShouldBe("tag-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diff_flags_Removed_equipment_and_its_tags()
|
||||
{
|
||||
var uuid1 = Guid.NewGuid();
|
||||
var before = SnapshotWith(
|
||||
equipment: [Eq("eq-1", uuid1), Eq("eq-2", Guid.NewGuid())],
|
||||
tags: [Tag("tag-1", "A"), Tag("tag-2", "B")]);
|
||||
var after = SnapshotWith(
|
||||
equipment: [Eq("eq-2", before.Equipment[1].EquipmentUuid)],
|
||||
tags: [Tag("tag-2", "B")]);
|
||||
|
||||
var diff = GenerationDiffer.Compute(before, after);
|
||||
|
||||
diff.Equipment.ShouldContain(c => c.Kind == ChangeKind.Removed && c.LogicalId == "eq-1");
|
||||
diff.Tags.ShouldContain(c => c.Kind == ChangeKind.Removed && c.LogicalId == "tag-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_dispatches_callbacks_in_dependency_order_and_survives_idempotent_retry()
|
||||
{
|
||||
var callLog = new List<string>();
|
||||
var applier = new GenerationApplier(new ApplyCallbacks
|
||||
{
|
||||
OnDriver = (c, _) => { callLog.Add($"drv:{c.Kind}:{c.LogicalId}"); return Task.CompletedTask; },
|
||||
OnEquipment = (c, _) => { callLog.Add($"eq:{c.Kind}:{c.LogicalId}"); return Task.CompletedTask; },
|
||||
OnTag = (c, _) => { callLog.Add($"tag:{c.Kind}:{c.LogicalId}"); return Task.CompletedTask; },
|
||||
});
|
||||
|
||||
var to = SnapshotWith(
|
||||
drivers: [Driver("d-1")],
|
||||
equipment: [Eq("eq-1", Guid.NewGuid())],
|
||||
tags: [Tag("tag-1", "A")]);
|
||||
|
||||
var result1 = await applier.ApplyAsync(from: null, to, CancellationToken.None);
|
||||
result1.Succeeded.ShouldBeTrue();
|
||||
|
||||
// Driver Added must come before Equipment Added must come before Tag Added
|
||||
var drvIdx = callLog.FindIndex(s => s.StartsWith("drv:Added"));
|
||||
var eqIdx = callLog.FindIndex(s => s.StartsWith("eq:Added"));
|
||||
var tagIdx = callLog.FindIndex(s => s.StartsWith("tag:Added"));
|
||||
drvIdx.ShouldBeLessThan(eqIdx);
|
||||
eqIdx.ShouldBeLessThan(tagIdx);
|
||||
|
||||
// Idempotent retry: re-applying the same diff must not blow up
|
||||
var countBefore = callLog.Count;
|
||||
var result2 = await applier.ApplyAsync(from: null, to, CancellationToken.None);
|
||||
result2.Succeeded.ShouldBeTrue();
|
||||
callLog.Count.ShouldBe(countBefore * 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_collects_errors_from_failing_callback_without_aborting()
|
||||
{
|
||||
var applier = new GenerationApplier(new ApplyCallbacks
|
||||
{
|
||||
OnTag = (c, _) =>
|
||||
c.LogicalId == "tag-bad"
|
||||
? throw new InvalidOperationException("simulated")
|
||||
: Task.CompletedTask,
|
||||
});
|
||||
|
||||
var to = SnapshotWith(tags: [Tag("tag-ok", "A"), Tag("tag-bad", "B")]);
|
||||
var result = await applier.ApplyAsync(from: null, to, CancellationToken.None);
|
||||
|
||||
result.Succeeded.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("tag-bad") && e.Contains("simulated"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class GenerationSealedCacheTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), $"otopcua-sealed-{Guid.NewGuid():N}");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(_root)) return;
|
||||
// Remove ReadOnly attribute first so Directory.Delete can clean sealed files.
|
||||
foreach (var f in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories))
|
||||
File.SetAttributes(f, FileAttributes.Normal);
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
catch { /* best-effort cleanup */ }
|
||||
}
|
||||
|
||||
private GenerationSnapshot MakeSnapshot(string clusterId, long generationId, string payload = "{\"sample\":true}") =>
|
||||
new()
|
||||
{
|
||||
ClusterId = clusterId,
|
||||
GenerationId = generationId,
|
||||
CachedAt = DateTime.UtcNow,
|
||||
PayloadJson = payload,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task FirstBoot_NoSnapshot_ReadThrows()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
|
||||
await Should.ThrowAsync<GenerationCacheUnavailableException>(
|
||||
() => cache.ReadCurrentAsync("cluster-a"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SealThenRead_RoundTrips()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
var snapshot = MakeSnapshot("cluster-a", 42, "{\"hello\":\"world\"}");
|
||||
|
||||
await cache.SealAsync(snapshot);
|
||||
|
||||
var read = await cache.ReadCurrentAsync("cluster-a");
|
||||
read.GenerationId.ShouldBe(42);
|
||||
read.ClusterId.ShouldBe("cluster-a");
|
||||
read.PayloadJson.ShouldBe("{\"hello\":\"world\"}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SealedFile_IsReadOnly_OnDisk()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 5));
|
||||
|
||||
var sealedPath = Path.Combine(_root, "cluster-a", "5.db");
|
||||
File.Exists(sealedPath).ShouldBeTrue();
|
||||
var attrs = File.GetAttributes(sealedPath);
|
||||
attrs.HasFlag(FileAttributes.ReadOnly).ShouldBeTrue("sealed file must be read-only");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SealingTwoGenerations_PointerAdvances_ToLatest()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 1));
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 2));
|
||||
|
||||
cache.TryGetCurrentGenerationId("cluster-a").ShouldBe(2);
|
||||
var read = await cache.ReadCurrentAsync("cluster-a");
|
||||
read.GenerationId.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PriorGenerationFile_Survives_AfterNewSeal()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 1));
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 2));
|
||||
|
||||
File.Exists(Path.Combine(_root, "cluster-a", "1.db")).ShouldBeTrue(
|
||||
"prior generations preserved for audit; pruning is separate");
|
||||
File.Exists(Path.Combine(_root, "cluster-a", "2.db")).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorruptSealedFile_ReadFailsClosed()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 7));
|
||||
|
||||
// Corrupt the sealed file: clear read-only, truncate, leave pointer intact.
|
||||
var sealedPath = Path.Combine(_root, "cluster-a", "7.db");
|
||||
File.SetAttributes(sealedPath, FileAttributes.Normal);
|
||||
File.WriteAllBytes(sealedPath, [0x00, 0x01, 0x02]);
|
||||
|
||||
await Should.ThrowAsync<GenerationCacheUnavailableException>(
|
||||
() => cache.ReadCurrentAsync("cluster-a"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingSealedFile_ReadFailsClosed()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 3));
|
||||
|
||||
// Delete the sealed file but leave the pointer — corruption scenario.
|
||||
var sealedPath = Path.Combine(_root, "cluster-a", "3.db");
|
||||
File.SetAttributes(sealedPath, FileAttributes.Normal);
|
||||
File.Delete(sealedPath);
|
||||
|
||||
await Should.ThrowAsync<GenerationCacheUnavailableException>(
|
||||
() => cache.ReadCurrentAsync("cluster-a"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorruptPointerFile_ReadFailsClosed()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 9));
|
||||
|
||||
var pointerPath = Path.Combine(_root, "cluster-a", "CURRENT");
|
||||
File.WriteAllText(pointerPath, "not-a-number");
|
||||
|
||||
await Should.ThrowAsync<GenerationCacheUnavailableException>(
|
||||
() => cache.ReadCurrentAsync("cluster-a"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SealSameGenerationTwice_IsIdempotent()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 11));
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 11, "{\"v\":2}"));
|
||||
|
||||
var read = await cache.ReadCurrentAsync("cluster-a");
|
||||
read.PayloadJson.ShouldBe("{\"sample\":true}", "sealed file is immutable; second seal no-ops");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IndependentClusters_DoNotInterfere()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 1));
|
||||
await cache.SealAsync(MakeSnapshot("cluster-b", 10));
|
||||
|
||||
(await cache.ReadCurrentAsync("cluster-a")).GenerationId.ShouldBe(1);
|
||||
(await cache.ReadCurrentAsync("cluster-b")).GenerationId.ShouldBe(10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
{
|
||||
private readonly OtOpcUaConfigDbContext _db;
|
||||
|
||||
public LdapGroupRoleMappingServiceTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase($"ldap-grm-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
_db = new OtOpcUaConfigDbContext(options);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
private LdapGroupRoleMapping Make(string group, AdminRole role, string? clusterId = null, bool? isSystemWide = null) =>
|
||||
new()
|
||||
{
|
||||
LdapGroup = group,
|
||||
Role = role,
|
||||
ClusterId = clusterId,
|
||||
IsSystemWide = isSystemWide ?? (clusterId is null),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Create_SetsId_AndCreatedAtUtc()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var row = Make("cn=fleet,dc=x", AdminRole.FleetAdmin);
|
||||
|
||||
var saved = await svc.CreateAsync(row, CancellationToken.None);
|
||||
|
||||
saved.Id.ShouldNotBe(Guid.Empty);
|
||||
saved.CreatedAtUtc.ShouldBeGreaterThan(DateTime.UtcNow.AddMinutes(-1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Rejects_EmptyLdapGroup()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var row = Make("", AdminRole.FleetAdmin);
|
||||
|
||||
await Should.ThrowAsync<InvalidLdapGroupRoleMappingException>(
|
||||
() => svc.CreateAsync(row, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Rejects_SystemWide_With_ClusterId()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var row = Make("cn=g", AdminRole.ConfigViewer, clusterId: "c1", isSystemWide: true);
|
||||
|
||||
await Should.ThrowAsync<InvalidLdapGroupRoleMappingException>(
|
||||
() => svc.CreateAsync(row, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Rejects_NonSystemWide_WithoutClusterId()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var row = Make("cn=g", AdminRole.ConfigViewer, clusterId: null, isSystemWide: false);
|
||||
|
||||
await Should.ThrowAsync<InvalidLdapGroupRoleMappingException>(
|
||||
() => svc.CreateAsync(row, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByGroups_Returns_MatchingGrants_Only()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=editor,dc=x", AdminRole.ConfigEditor), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=viewer,dc=x", AdminRole.ConfigViewer), CancellationToken.None);
|
||||
|
||||
var results = await svc.GetByGroupsAsync(
|
||||
["cn=fleet,dc=x", "cn=viewer,dc=x"], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(2);
|
||||
results.Select(r => r.Role).ShouldBe([AdminRole.FleetAdmin, AdminRole.ConfigViewer], ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByGroups_Empty_Input_ReturnsEmpty()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
|
||||
|
||||
var results = await svc.GetByGroupsAsync([], CancellationToken.None);
|
||||
|
||||
results.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAll_Orders_ByGroupThenCluster()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
await svc.CreateAsync(Make("cn=b,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=a,dc=x", AdminRole.ConfigEditor, clusterId: "c2", isSystemWide: false), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=a,dc=x", AdminRole.ConfigEditor, clusterId: "c1", isSystemWide: false), CancellationToken.None);
|
||||
|
||||
var results = await svc.ListAllAsync(CancellationToken.None);
|
||||
|
||||
results[0].LdapGroup.ShouldBe("cn=a,dc=x");
|
||||
results[0].ClusterId.ShouldBe("c1");
|
||||
results[1].ClusterId.ShouldBe("c2");
|
||||
results[2].LdapGroup.ShouldBe("cn=b,dc=x");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_Removes_Matching_Row()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var saved = await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
|
||||
|
||||
await svc.DeleteAsync(saved.Id, CancellationToken.None);
|
||||
|
||||
var after = await svc.ListAllAsync(CancellationToken.None);
|
||||
after.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_Unknown_Id_IsNoOp()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
|
||||
await svc.DeleteAsync(Guid.NewGuid(), CancellationToken.None);
|
||||
// no exception
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class LiteDbConfigCacheTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-cache-test-{Guid.NewGuid():N}.db");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_dbPath)) File.Delete(_dbPath);
|
||||
}
|
||||
|
||||
private GenerationSnapshot Snapshot(string cluster, long gen) => new()
|
||||
{
|
||||
ClusterId = cluster,
|
||||
GenerationId = gen,
|
||||
CachedAt = DateTime.UtcNow,
|
||||
PayloadJson = $"{{\"g\":{gen}}}",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Roundtrip_preserves_payload()
|
||||
{
|
||||
using var cache = new LiteDbConfigCache(_dbPath);
|
||||
var put = Snapshot("c-1", 42);
|
||||
await cache.PutAsync(put);
|
||||
|
||||
var got = await cache.GetMostRecentAsync("c-1");
|
||||
got.ShouldNotBeNull();
|
||||
got!.GenerationId.ShouldBe(42);
|
||||
got.PayloadJson.ShouldBe(put.PayloadJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMostRecent_returns_latest_when_multiple_generations_present()
|
||||
{
|
||||
using var cache = new LiteDbConfigCache(_dbPath);
|
||||
foreach (var g in new long[] { 10, 20, 15 })
|
||||
await cache.PutAsync(Snapshot("c-1", g));
|
||||
|
||||
var got = await cache.GetMostRecentAsync("c-1");
|
||||
got!.GenerationId.ShouldBe(20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMostRecent_returns_null_for_unknown_cluster()
|
||||
{
|
||||
using var cache = new LiteDbConfigCache(_dbPath);
|
||||
(await cache.GetMostRecentAsync("ghost")).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Prune_keeps_latest_N_and_drops_older()
|
||||
{
|
||||
using var cache = new LiteDbConfigCache(_dbPath);
|
||||
for (long g = 1; g <= 15; g++)
|
||||
await cache.PutAsync(Snapshot("c-1", g));
|
||||
|
||||
await cache.PruneOldGenerationsAsync("c-1", keepLatest: 10);
|
||||
|
||||
(await cache.GetMostRecentAsync("c-1"))!.GenerationId.ShouldBe(15);
|
||||
|
||||
// Drop them one by one and count — should be exactly 10 remaining
|
||||
var count = 0;
|
||||
while (await cache.GetMostRecentAsync("c-1") is not null)
|
||||
{
|
||||
count++;
|
||||
await cache.PruneOldGenerationsAsync("c-1", keepLatest: Math.Max(0, 10 - count));
|
||||
if (count > 20) break; // safety
|
||||
}
|
||||
count.ShouldBe(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_same_cluster_generation_twice_replaces_not_duplicates()
|
||||
{
|
||||
using var cache = new LiteDbConfigCache(_dbPath);
|
||||
var first = Snapshot("c-1", 1);
|
||||
first.PayloadJson = "{\"v\":1}";
|
||||
await cache.PutAsync(first);
|
||||
|
||||
var second = Snapshot("c-1", 1);
|
||||
second.PayloadJson = "{\"v\":2}";
|
||||
await cache.PutAsync(second);
|
||||
|
||||
(await cache.GetMostRecentAsync("c-1"))!.PayloadJson.ShouldBe("{\"v\":2}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Corrupt_file_surfaces_as_LocalConfigCacheCorruptException()
|
||||
{
|
||||
// Write a file large enough to look like a LiteDB page but with garbage contents so page
|
||||
// deserialization fails on the first read probe.
|
||||
File.WriteAllBytes(_dbPath, new byte[8192]);
|
||||
Array.Fill<byte>(File.ReadAllBytes(_dbPath), 0xAB);
|
||||
using (var fs = File.OpenWrite(_dbPath))
|
||||
{
|
||||
fs.Write(new byte[8192].Select(_ => (byte)0xAB).ToArray());
|
||||
}
|
||||
|
||||
Should.Throw<LocalConfigCacheCorruptException>(() => new LiteDbConfigCache(_dbPath));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the Phase 7 Stream E entities (<see cref="Script"/>, <see cref="VirtualTag"/>,
|
||||
/// <see cref="ScriptedAlarm"/>, <see cref="ScriptedAlarmState"/>) register correctly in
|
||||
/// the EF model, map to the expected tables/columns/indexes, and carry the check constraints
|
||||
/// the plan decisions call for. Introspection only — no SQL Server required.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class Phase7ScriptingEntitiesTests
|
||||
{
|
||||
private static OtOpcUaConfigDbContext BuildCtx()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseSqlServer("Server=(local);Database=dummy;Integrated Security=true") // not connected
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(options);
|
||||
}
|
||||
|
||||
private static Microsoft.EntityFrameworkCore.Metadata.IModel DesignModel(OtOpcUaConfigDbContext ctx)
|
||||
=> ctx.GetService<IDesignTimeModel>().Model;
|
||||
|
||||
[Fact]
|
||||
public void Script_entity_registered_with_expected_table_and_columns()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = ctx.Model.FindEntityType(typeof(Script)).ShouldNotBeNull();
|
||||
|
||||
entity.GetTableName().ShouldBe("Script");
|
||||
entity.FindProperty(nameof(Script.ScriptRowId)).ShouldNotBeNull();
|
||||
entity.FindProperty(nameof(Script.ScriptId)).ShouldNotBeNull()
|
||||
.GetMaxLength().ShouldBe(64);
|
||||
entity.FindProperty(nameof(Script.SourceCode)).ShouldNotBeNull()
|
||||
.GetColumnType().ShouldBe("nvarchar(max)");
|
||||
entity.FindProperty(nameof(Script.SourceHash)).ShouldNotBeNull()
|
||||
.GetMaxLength().ShouldBe(64);
|
||||
entity.FindProperty(nameof(Script.Language)).ShouldNotBeNull()
|
||||
.GetMaxLength().ShouldBe(16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Script_has_unique_logical_id_per_generation()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = ctx.Model.FindEntityType(typeof(Script)).ShouldNotBeNull();
|
||||
entity.GetIndexes().ShouldContain(
|
||||
i => i.IsUnique && i.GetDatabaseName() == "UX_Script_Generation_LogicalId");
|
||||
entity.GetIndexes().ShouldContain(
|
||||
i => i.GetDatabaseName() == "IX_Script_Generation_SourceHash");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VirtualTag_entity_registered_with_trigger_check_constraint()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = DesignModel(ctx).FindEntityType(typeof(VirtualTag)).ShouldNotBeNull();
|
||||
entity.GetTableName().ShouldBe("VirtualTag");
|
||||
|
||||
var checks = entity.GetCheckConstraints().Select(c => c.Name).ToArray();
|
||||
checks.ShouldContain("CK_VirtualTag_Trigger_AtLeastOne");
|
||||
checks.ShouldContain("CK_VirtualTag_TimerInterval_Min");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VirtualTag_enforces_unique_name_per_Equipment()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = ctx.Model.FindEntityType(typeof(VirtualTag)).ShouldNotBeNull();
|
||||
entity.GetIndexes().ShouldContain(
|
||||
i => i.IsUnique && i.GetDatabaseName() == "UX_VirtualTag_Generation_EquipmentPath");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VirtualTag_has_ChangeTriggered_and_Historize_flags()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = ctx.Model.FindEntityType(typeof(VirtualTag)).ShouldNotBeNull();
|
||||
entity.FindProperty(nameof(VirtualTag.ChangeTriggered)).ShouldNotBeNull()
|
||||
.ClrType.ShouldBe(typeof(bool));
|
||||
entity.FindProperty(nameof(VirtualTag.Historize)).ShouldNotBeNull()
|
||||
.ClrType.ShouldBe(typeof(bool));
|
||||
entity.FindProperty(nameof(VirtualTag.TimerIntervalMs)).ShouldNotBeNull()
|
||||
.ClrType.ShouldBe(typeof(int?));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptedAlarm_entity_registered_with_severity_and_type_checks()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = DesignModel(ctx).FindEntityType(typeof(ScriptedAlarm)).ShouldNotBeNull();
|
||||
entity.GetTableName().ShouldBe("ScriptedAlarm");
|
||||
|
||||
var checks = entity.GetCheckConstraints().Select(c => c.Name).ToArray();
|
||||
checks.ShouldContain("CK_ScriptedAlarm_Severity_Range");
|
||||
checks.ShouldContain("CK_ScriptedAlarm_AlarmType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptedAlarm_has_HistorizeToAveva_default_true_per_plan_decision_15()
|
||||
{
|
||||
// Defaults live on the CLR default assignment — verify the initializer.
|
||||
var alarm = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "a1",
|
||||
EquipmentId = "eq1",
|
||||
Name = "n",
|
||||
AlarmType = "LimitAlarm",
|
||||
MessageTemplate = "m",
|
||||
PredicateScriptId = "s1",
|
||||
};
|
||||
alarm.HistorizeToAveva.ShouldBeTrue();
|
||||
alarm.Retain.ShouldBeTrue();
|
||||
alarm.Severity.ShouldBe(500);
|
||||
alarm.Enabled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptedAlarmState_keyed_on_ScriptedAlarmId_not_generation_scoped()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = ctx.Model.FindEntityType(typeof(ScriptedAlarmState)).ShouldNotBeNull();
|
||||
entity.GetTableName().ShouldBe("ScriptedAlarmState");
|
||||
|
||||
var pk = entity.FindPrimaryKey().ShouldNotBeNull();
|
||||
pk.Properties.Count.ShouldBe(1);
|
||||
pk.Properties[0].Name.ShouldBe(nameof(ScriptedAlarmState.ScriptedAlarmId));
|
||||
|
||||
// State is NOT generation-scoped — GenerationId column should not exist per plan decision #14.
|
||||
entity.FindProperty("GenerationId").ShouldBeNull(
|
||||
"ack state follows alarm identity across generations");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptedAlarmState_default_state_values_match_Part9_initial_states()
|
||||
{
|
||||
var state = new ScriptedAlarmState
|
||||
{
|
||||
ScriptedAlarmId = "a1",
|
||||
EnabledState = "Enabled",
|
||||
AckedState = "Unacknowledged",
|
||||
ConfirmedState = "Unconfirmed",
|
||||
ShelvingState = "Unshelved",
|
||||
};
|
||||
state.CommentsJson.ShouldBe("[]");
|
||||
state.LastAckUser.ShouldBeNull();
|
||||
state.LastAckUtc.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptedAlarmState_has_JSON_check_constraint_on_CommentsJson()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = DesignModel(ctx).FindEntityType(typeof(ScriptedAlarmState)).ShouldNotBeNull();
|
||||
var checks = entity.GetCheckConstraints().Select(c => c.Name).ToArray();
|
||||
checks.ShouldContain("CK_ScriptedAlarmState_CommentsJson_IsJson");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_new_entities_exposed_via_DbSet()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
ctx.Scripts.ShouldNotBeNull();
|
||||
ctx.VirtualTags.ShouldNotBeNull();
|
||||
ctx.ScriptedAlarms.ShouldNotBeNull();
|
||||
ctx.ScriptedAlarmStates.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddPhase7ScriptingTables_migration_exists_in_assembly()
|
||||
{
|
||||
// The migration type carries the design-time snapshot + Up/Down methods EF uses to
|
||||
// apply the schema. Missing = schema won't roll forward in deployments.
|
||||
var t = typeof(Migrations.AddPhase7ScriptingTables);
|
||||
t.ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ResilientConfigReaderTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), $"otopcua-reader-{Guid.NewGuid():N}");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(_root)) return;
|
||||
foreach (var f in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories))
|
||||
File.SetAttributes(f, FileAttributes.Normal);
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CentralDbSucceeds_ReturnsValue_MarksFresh()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
var flag = new StaleConfigFlag { };
|
||||
flag.MarkStale(); // pre-existing stale state
|
||||
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance);
|
||||
|
||||
var result = await reader.ReadAsync(
|
||||
"cluster-a",
|
||||
_ => ValueTask.FromResult("fresh-from-db"),
|
||||
_ => "from-cache",
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe("fresh-from-db");
|
||||
flag.IsStale.ShouldBeFalse("successful central-DB read clears stale flag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CentralDbFails_ExhaustsRetries_FallsBackToCache_MarksStale()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
await cache.SealAsync(new GenerationSnapshot
|
||||
{
|
||||
ClusterId = "cluster-a", GenerationId = 99, CachedAt = DateTime.UtcNow,
|
||||
PayloadJson = "{\"cached\":true}",
|
||||
});
|
||||
var flag = new StaleConfigFlag();
|
||||
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
||||
timeout: TimeSpan.FromSeconds(10), retryCount: 2);
|
||||
var attempts = 0;
|
||||
|
||||
var result = await reader.ReadAsync(
|
||||
"cluster-a",
|
||||
_ =>
|
||||
{
|
||||
attempts++;
|
||||
throw new InvalidOperationException("SQL dead");
|
||||
#pragma warning disable CS0162
|
||||
return ValueTask.FromResult("never");
|
||||
#pragma warning restore CS0162
|
||||
},
|
||||
snap => snap.PayloadJson,
|
||||
CancellationToken.None);
|
||||
|
||||
attempts.ShouldBe(3, "1 initial + 2 retries = 3 attempts");
|
||||
result.ShouldBe("{\"cached\":true}");
|
||||
flag.IsStale.ShouldBeTrue("cache fallback flips stale flag true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CentralDbFails_AndCacheAlsoUnavailable_Throws()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
var flag = new StaleConfigFlag();
|
||||
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
||||
timeout: TimeSpan.FromSeconds(10), retryCount: 0);
|
||||
|
||||
await Should.ThrowAsync<GenerationCacheUnavailableException>(async () =>
|
||||
{
|
||||
await reader.ReadAsync<string>(
|
||||
"cluster-a",
|
||||
_ => throw new InvalidOperationException("SQL dead"),
|
||||
_ => "never",
|
||||
CancellationToken.None);
|
||||
});
|
||||
|
||||
flag.IsStale.ShouldBeFalse("no snapshot ever served, so flag stays whatever it was");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_NotRetried()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
var flag = new StaleConfigFlag();
|
||||
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
||||
timeout: TimeSpan.FromSeconds(10), retryCount: 5);
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
var attempts = 0;
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
{
|
||||
await reader.ReadAsync<string>(
|
||||
"cluster-a",
|
||||
ct =>
|
||||
{
|
||||
attempts++;
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return ValueTask.FromResult("ok");
|
||||
},
|
||||
_ => "cache",
|
||||
cts.Token);
|
||||
});
|
||||
|
||||
attempts.ShouldBeLessThanOrEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class StaleConfigFlagTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_IsFresh()
|
||||
{
|
||||
new StaleConfigFlag().IsStale.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkStale_ThenFresh_Toggles()
|
||||
{
|
||||
var flag = new StaleConfigFlag();
|
||||
flag.MarkStale();
|
||||
flag.IsStale.ShouldBeTrue();
|
||||
flag.MarkFresh();
|
||||
flag.IsStale.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConcurrentWrites_Converge()
|
||||
{
|
||||
var flag = new StaleConfigFlag();
|
||||
Parallel.For(0, 1000, i =>
|
||||
{
|
||||
if (i % 2 == 0) flag.MarkStale(); else flag.MarkFresh();
|
||||
});
|
||||
flag.MarkFresh();
|
||||
flag.IsStale.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Spins up a dedicated test database, applies the EF migrations against it, and exposes a
|
||||
/// <see cref="SqlConnection"/> factory. Disposed at collection teardown (drops the DB).
|
||||
/// Gated by the <c>OTOPCUA_CONFIG_TEST_SERVER</c> env var so CI runs can opt in explicitly;
|
||||
/// local runs default to the dev container on <c>localhost:14330</c>.
|
||||
/// </summary>
|
||||
public sealed class SchemaComplianceFixture : IDisposable
|
||||
{
|
||||
private const string DefaultServer = "localhost,14330";
|
||||
private const string DefaultSaPassword = "OtOpcUaDev_2026!";
|
||||
|
||||
public string DatabaseName { get; }
|
||||
public string ConnectionString { get; }
|
||||
|
||||
public SchemaComplianceFixture()
|
||||
{
|
||||
var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer;
|
||||
var saPassword = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword;
|
||||
|
||||
DatabaseName = $"OtOpcUaConfig_Test_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}";
|
||||
ConnectionString =
|
||||
$"Server={server};Database={DatabaseName};User Id=sa;Password={saPassword};TrustServerCertificate=True;Encrypt=False;";
|
||||
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseSqlServer(ConnectionString)
|
||||
.Options;
|
||||
|
||||
using var ctx = new OtOpcUaConfigDbContext(options);
|
||||
ctx.Database.Migrate();
|
||||
}
|
||||
|
||||
public SqlConnection OpenConnection()
|
||||
{
|
||||
var conn = new SqlConnection(ConnectionString);
|
||||
conn.Open();
|
||||
return conn;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
var masterConnection =
|
||||
new SqlConnectionStringBuilder(ConnectionString) { InitialCatalog = "master" }.ConnectionString;
|
||||
|
||||
using var conn = new SqlConnection(masterConnection);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition(nameof(SchemaComplianceCollection))]
|
||||
public sealed class SchemaComplianceCollection : ICollectionFixture<SchemaComplianceFixture>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Introspects the applied schema via <c>sys.*</c> / <c>INFORMATION_SCHEMA.*</c> to confirm that
|
||||
/// the Fluent-API DbContext produces the exact structure specified in
|
||||
/// <c>docs/v2/config-db-schema.md</c>. Any change here is a deliberate decision — update the
|
||||
/// schema doc first, then these tests.
|
||||
/// </summary>
|
||||
[Trait("Category", "SchemaCompliance")]
|
||||
[Collection(nameof(SchemaComplianceCollection))]
|
||||
public sealed class SchemaComplianceTests
|
||||
{
|
||||
private readonly SchemaComplianceFixture _fixture;
|
||||
|
||||
public SchemaComplianceTests(SchemaComplianceFixture fixture) => _fixture = fixture;
|
||||
|
||||
[Fact]
|
||||
public void All_expected_tables_exist()
|
||||
{
|
||||
var expected = new[]
|
||||
{
|
||||
"ServerCluster", "ClusterNode", "ClusterNodeCredential", "ClusterNodeGenerationState",
|
||||
"ConfigGeneration", "ConfigAuditLog",
|
||||
"Namespace", "UnsArea", "UnsLine",
|
||||
"DriverInstance", "Device", "Equipment", "Tag", "PollGroup", "VirtualTag",
|
||||
"NodeAcl", "ExternalIdReservation",
|
||||
"DriverHostStatus",
|
||||
"DriverInstanceResilienceStatus",
|
||||
"LdapGroupRoleMapping",
|
||||
"EquipmentImportBatch",
|
||||
"EquipmentImportRow",
|
||||
"Script", "ScriptedAlarm", "ScriptedAlarmState",
|
||||
};
|
||||
|
||||
var actual = QueryStrings(@"
|
||||
SELECT name FROM sys.tables WHERE name <> '__EFMigrationsHistory' ORDER BY name;").ToHashSet();
|
||||
|
||||
foreach (var table in expected)
|
||||
actual.ShouldContain(table, $"missing table: {table}");
|
||||
|
||||
actual.Count.ShouldBe(expected.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Filtered_unique_indexes_match_schema_spec()
|
||||
{
|
||||
// (IndexName, Filter, Uniqueness) tuples — from OtOpcUaConfigDbContext Fluent config.
|
||||
// Kept here as a spec-level source of truth; the test ensures EF generated them verbatim.
|
||||
var expected = new[]
|
||||
{
|
||||
("UX_ClusterNode_Primary_Per_Cluster", "([RedundancyRole]='Primary')"),
|
||||
("UX_ClusterNodeCredential_Value", "([Enabled]=(1))"),
|
||||
("UX_ConfigGeneration_Draft_Per_Cluster", "([Status]='Draft')"),
|
||||
("UX_ExternalIdReservation_KindValue_Active", "([ReleasedAt] IS NULL)"),
|
||||
};
|
||||
|
||||
var rows = QueryRows(@"
|
||||
SELECT i.name AS IndexName, i.filter_definition
|
||||
FROM sys.indexes i
|
||||
WHERE i.is_unique = 1 AND i.has_filter = 1;",
|
||||
r => (Name: r.GetString(0), Filter: r.IsDBNull(1) ? null : r.GetString(1)));
|
||||
|
||||
foreach (var (name, filter) in expected)
|
||||
{
|
||||
var match = rows.FirstOrDefault(x => x.Name == name);
|
||||
match.Name.ShouldBe(name, $"missing filtered unique index: {name}");
|
||||
NormalizeFilter(match.Filter).ShouldBe(NormalizeFilter(filter),
|
||||
$"filter predicate for {name} drifted");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Check_constraints_match_schema_spec()
|
||||
{
|
||||
var expected = new[]
|
||||
{
|
||||
"CK_ServerCluster_RedundancyMode_NodeCount",
|
||||
"CK_Device_DeviceConfig_IsJson",
|
||||
"CK_DriverInstance_DriverConfig_IsJson",
|
||||
"CK_DriverInstance_ResilienceConfig_IsJson",
|
||||
"CK_PollGroup_IntervalMs_Min",
|
||||
"CK_Tag_TagConfig_IsJson",
|
||||
"CK_ConfigAuditLog_DetailsJson_IsJson",
|
||||
};
|
||||
|
||||
var actual = QueryStrings("SELECT name FROM sys.check_constraints ORDER BY name;").ToHashSet();
|
||||
|
||||
foreach (var ck in expected)
|
||||
actual.ShouldContain(ck, $"missing CHECK constraint: {ck}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Json_check_constraints_use_IsJson_function()
|
||||
{
|
||||
var rows = QueryRows(@"
|
||||
SELECT cc.name, cc.definition
|
||||
FROM sys.check_constraints cc
|
||||
WHERE cc.name LIKE 'CK_%_IsJson';",
|
||||
r => (Name: r.GetString(0), Definition: r.GetString(1)));
|
||||
|
||||
rows.Count.ShouldBeGreaterThanOrEqualTo(4);
|
||||
|
||||
foreach (var (name, definition) in rows)
|
||||
definition.ShouldContain("isjson(", Case.Insensitive,
|
||||
$"{name} definition does not call ISJSON: {definition}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigGeneration_Status_uses_nvarchar_enum_storage()
|
||||
{
|
||||
var rows = QueryRows(@"
|
||||
SELECT c.COLUMN_NAME, c.DATA_TYPE, c.CHARACTER_MAXIMUM_LENGTH
|
||||
FROM INFORMATION_SCHEMA.COLUMNS c
|
||||
WHERE c.TABLE_NAME = 'ConfigGeneration' AND c.COLUMN_NAME = 'Status';",
|
||||
r => (Column: r.GetString(0), Type: r.GetString(1), Length: r.IsDBNull(2) ? (int?)null : r.GetInt32(2)));
|
||||
|
||||
rows.Count.ShouldBe(1);
|
||||
rows[0].Type.ShouldBe("nvarchar");
|
||||
rows[0].Length.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equipment_carries_Opc40010_identity_fields()
|
||||
{
|
||||
var columns = QueryStrings(@"
|
||||
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'Equipment';")
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var col in new[]
|
||||
{
|
||||
"EquipmentUuid", "EquipmentId", "MachineCode", "ZTag", "SAPID",
|
||||
"Manufacturer", "Model", "SerialNumber",
|
||||
})
|
||||
columns.ShouldContain(col, $"Equipment missing expected column: {col}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Namespace_has_same_cluster_invariant_index()
|
||||
{
|
||||
// Decision #122: namespace logical IDs unique within a cluster + generation. The composite
|
||||
// unique index enforces that trust boundary.
|
||||
var indexes = QueryStrings(@"
|
||||
SELECT i.name
|
||||
FROM sys.indexes i
|
||||
JOIN sys.tables t ON i.object_id = t.object_id
|
||||
WHERE t.name = 'Namespace' AND i.is_unique = 1;").ToList();
|
||||
|
||||
indexes.ShouldContain("UX_Namespace_Generation_LogicalId_Cluster");
|
||||
}
|
||||
|
||||
private List<string> QueryStrings(string sql)
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
using var cmd = new SqlCommand(sql, conn);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
var result = new List<string>();
|
||||
while (reader.Read())
|
||||
result.Add(reader.GetString(0));
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<T> QueryRows<T>(string sql, Func<SqlDataReader, T> project)
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
using var cmd = new SqlCommand(sql, conn);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
var result = new List<T>();
|
||||
while (reader.Read())
|
||||
result.Add(project(reader));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string? NormalizeFilter(string? filter) =>
|
||||
filter?.Replace(" ", string.Empty).Replace("(", string.Empty).Replace(")", string.Empty).ToLowerInvariant();
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Happy-path + representative error-path coverage per Task B.2 acceptance. Each test seeds its
|
||||
/// own cluster + node + credential, creates a draft, exercises one proc, then cleans up at the
|
||||
/// fixture level (the fixture drops the DB in Dispose).
|
||||
/// </summary>
|
||||
[Trait("Category", "StoredProcedures")]
|
||||
[Collection(nameof(SchemaComplianceCollection))]
|
||||
public sealed class StoredProceduresTests
|
||||
{
|
||||
private readonly SchemaComplianceFixture _fixture;
|
||||
|
||||
public StoredProceduresTests(SchemaComplianceFixture fixture) => _fixture = fixture;
|
||||
|
||||
[Fact]
|
||||
public void Publish_then_GetCurrent_returns_the_published_generation()
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
var (clusterId, nodeId, _, draftId) = SeedClusterWithDraft(conn, suffix: "pub1");
|
||||
|
||||
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
|
||||
("c", clusterId), ("g", draftId));
|
||||
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "EXEC dbo.sp_GetCurrentGenerationForCluster @NodeId=@n, @ClusterId=@c";
|
||||
cmd.Parameters.AddWithValue("n", nodeId);
|
||||
cmd.Parameters.AddWithValue("c", clusterId);
|
||||
using var r = cmd.ExecuteReader();
|
||||
r.Read().ShouldBeTrue("proc should return exactly one row");
|
||||
r.GetInt64(0).ShouldBe(draftId);
|
||||
r.GetString(2).ShouldBe("Published");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCurrent_rejects_caller_not_bound_to_node()
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
var (clusterId, _, _, _) = SeedClusterWithDraft(conn, suffix: "unauth");
|
||||
|
||||
var ex = Should.Throw<SqlException>(() =>
|
||||
Exec(conn, "EXEC dbo.sp_GetCurrentGenerationForCluster @NodeId=@n, @ClusterId=@c",
|
||||
("n", "ghost-node"), ("c", clusterId)));
|
||||
ex.Message.ShouldContain("Unauthorized");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Publish_second_draft_supersedes_first()
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
var (clusterId, _, _, draft1) = SeedClusterWithDraft(conn, suffix: "sup");
|
||||
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
|
||||
("c", clusterId), ("g", draft1));
|
||||
|
||||
var draft2 = CreateDraft(conn, clusterId);
|
||||
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
|
||||
("c", clusterId), ("g", draft2));
|
||||
|
||||
var status1 = Scalar<string>(conn,
|
||||
"SELECT Status FROM dbo.ConfigGeneration WHERE GenerationId = @g", ("g", draft1));
|
||||
var status2 = Scalar<string>(conn,
|
||||
"SELECT Status FROM dbo.ConfigGeneration WHERE GenerationId = @g", ("g", draft2));
|
||||
status1.ShouldBe("Superseded");
|
||||
status2.ShouldBe("Published");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Publish_rejects_non_draft_generation()
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
var (clusterId, _, _, draftId) = SeedClusterWithDraft(conn, suffix: "twice");
|
||||
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
|
||||
("c", clusterId), ("g", draftId));
|
||||
|
||||
var ex = Should.Throw<SqlException>(() =>
|
||||
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
|
||||
("c", clusterId), ("g", draftId)));
|
||||
ex.Message.ShouldContain("not in Draft");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateDraft_rejects_orphan_tag()
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
var (clusterId, _, _, draftId) = SeedClusterWithDraft(conn, suffix: "orphan");
|
||||
Exec(conn, @"INSERT dbo.Tag (GenerationId, TagId, DriverInstanceId, Name, DataType, AccessLevel, WriteIdempotent, TagConfig)
|
||||
VALUES (@g, 'tag-1', 'missing-driver', 'X', 'Int32', 'Read', 0, '{}')",
|
||||
("g", draftId));
|
||||
|
||||
var ex = Should.Throw<SqlException>(() =>
|
||||
Exec(conn, "EXEC dbo.sp_ValidateDraft @DraftGenerationId=@g", ("g", draftId)));
|
||||
ex.Message.ShouldContain("unresolved DriverInstanceId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rollback_creates_new_published_generation_and_clones_rows()
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
var (clusterId, _, _, draftId) = SeedClusterWithDraft(conn, suffix: "rb");
|
||||
SeedMinimalDriverRow(conn, draftId, clusterId, driverInstanceId: "drv-a");
|
||||
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
|
||||
("c", clusterId), ("g", draftId));
|
||||
|
||||
Exec(conn, "EXEC dbo.sp_RollbackToGeneration @ClusterId=@c, @TargetGenerationId=@g, @Notes='test'",
|
||||
("c", clusterId), ("g", draftId));
|
||||
|
||||
var newlyPublishedCount = Scalar<int>(conn,
|
||||
@"SELECT COUNT(*) FROM dbo.ConfigGeneration
|
||||
WHERE ClusterId = @c AND Status = 'Published' AND GenerationId <> @g",
|
||||
("c", clusterId), ("g", draftId));
|
||||
newlyPublishedCount.ShouldBe(1);
|
||||
|
||||
var driverClonedCount = Scalar<int>(conn,
|
||||
@"SELECT COUNT(*) FROM dbo.DriverInstance di
|
||||
JOIN dbo.ConfigGeneration cg ON cg.GenerationId = di.GenerationId
|
||||
WHERE cg.ClusterId = @c AND cg.Status = 'Published' AND di.DriverInstanceId = 'drv-a'",
|
||||
("c", clusterId));
|
||||
driverClonedCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_returns_Added_for_driver_present_only_in_target()
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
var (clusterId, _, _, draft1) = SeedClusterWithDraft(conn, suffix: "diff");
|
||||
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
|
||||
("c", clusterId), ("g", draft1));
|
||||
|
||||
var draft2 = CreateDraft(conn, clusterId);
|
||||
SeedMinimalDriverRow(conn, draft2, clusterId, driverInstanceId: "drv-added");
|
||||
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
|
||||
("c", clusterId), ("g", draft2));
|
||||
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "EXEC dbo.sp_ComputeGenerationDiff @FromGenerationId=@f, @ToGenerationId=@t";
|
||||
cmd.Parameters.AddWithValue("f", draft1);
|
||||
cmd.Parameters.AddWithValue("t", draft2);
|
||||
using var r = cmd.ExecuteReader();
|
||||
var diffs = new List<(string Table, string Id, string Kind)>();
|
||||
while (r.Read())
|
||||
diffs.Add((r.GetString(0), r.GetString(1), r.GetString(2)));
|
||||
|
||||
diffs.ShouldContain(d => d.Table == "DriverInstance" && d.Id == "drv-added" && d.Kind == "Added");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReleaseReservation_requires_nonempty_reason()
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
var ex = Should.Throw<SqlException>(() =>
|
||||
Exec(conn, "EXEC dbo.sp_ReleaseExternalIdReservation @Kind='ZTag', @Value='X', @ReleaseReason=''"));
|
||||
ex.Message.ShouldContain("ReleaseReason is required");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
/// <summary>Creates a cluster, one node, one credential bound to the current SUSER_SNAME(), and an empty Draft.</summary>
|
||||
private static (string ClusterId, string NodeId, string Credential, long DraftId)
|
||||
SeedClusterWithDraft(SqlConnection conn, string suffix)
|
||||
{
|
||||
var clusterId = $"cluster-{suffix}";
|
||||
var nodeId = $"node-{suffix}-a";
|
||||
|
||||
// Every test uses the same SUSER_SNAME() ('sa' by default), and the credential unique index
|
||||
// is filtered on Enabled=1 across (Kind, Value) globally. To avoid collisions across tests
|
||||
// sharing one DB, we disable old credentials first.
|
||||
Exec(conn, "UPDATE dbo.ClusterNodeCredential SET Enabled = 0 WHERE Value = SUSER_SNAME();");
|
||||
|
||||
Exec(conn,
|
||||
@"INSERT dbo.ServerCluster (ClusterId, Name, Enterprise, Site, RedundancyMode, NodeCount, Enabled, CreatedBy)
|
||||
VALUES (@c, @c, 'zb', @s, 'None', 1, 1, SUSER_SNAME());
|
||||
INSERT dbo.ClusterNode (NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||
VALUES (@n, @c, 'Primary', 'localhost', 4840, 5001, CONCAT('urn:localhost:', @s), 200, 1, SUSER_SNAME());
|
||||
INSERT dbo.ClusterNodeCredential (NodeId, Kind, Value, Enabled, CreatedBy)
|
||||
VALUES (@n, 'SqlLogin', SUSER_SNAME(), 1, SUSER_SNAME());",
|
||||
("c", clusterId), ("n", nodeId), ("s", suffix));
|
||||
|
||||
var draftId = CreateDraft(conn, clusterId);
|
||||
return (clusterId, nodeId, "sa", draftId);
|
||||
}
|
||||
|
||||
private static long CreateDraft(SqlConnection conn, string clusterId)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
INSERT dbo.ConfigGeneration (ClusterId, Status, CreatedAt, CreatedBy)
|
||||
VALUES (@c, 'Draft', SYSUTCDATETIME(), SUSER_SNAME());
|
||||
SELECT CAST(SCOPE_IDENTITY() AS bigint);";
|
||||
cmd.Parameters.AddWithValue("c", clusterId);
|
||||
return (long)cmd.ExecuteScalar()!;
|
||||
}
|
||||
|
||||
private static void SeedMinimalDriverRow(SqlConnection conn, long genId, string clusterId, string driverInstanceId)
|
||||
{
|
||||
Exec(conn,
|
||||
@"INSERT dbo.Namespace (GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
|
||||
VALUES (@g, @ns, @c, 'Equipment', 'urn:ns', 1);
|
||||
INSERT dbo.DriverInstance (GenerationId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig)
|
||||
VALUES (@g, @drv, @c, @ns, 'drv', 'ModbusTcp', 1, '{}');",
|
||||
("g", genId), ("c", clusterId), ("ns", $"ns-{driverInstanceId}"), ("drv", driverInstanceId));
|
||||
}
|
||||
|
||||
private static void Exec(SqlConnection conn, string sql, params (string Name, object Value)[] parameters)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
foreach (var (name, value) in parameters) cmd.Parameters.AddWithValue(name, value);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static T Scalar<T>(SqlConnection conn, string sql, params (string Name, object Value)[] parameters)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
foreach (var (name, value) in parameters) cmd.Parameters.AddWithValue(name, value);
|
||||
return (T)cmd.ExecuteScalar()!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Configuration.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.1"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user