809e7886d8
The arch-review #10 sub-gap worried that per-instance ResilienceConfig never reaches the runtime pipeline. The threading is in fact wired end-to-end (task #13): ConfigComposer serialises the whole DriverInstance entity, so ResilienceConfig rides the artifact into DriverInstanceSpec, which DriverHostActor layers onto the driver's Polly pipeline. The only leg with no test was the real composer->artifact serialization — the existing DeploymentArtifactTests hand-build the JSON. Adds ResilienceConfig_survives_ConfigComposer_to_ParseDriverInstances_round_trip (mirrors the DeviceHost round-trip guard): seeds a DriverInstance WITH a non-default override + one WITHOUT, runs the real SnapshotAndFlattenAsync, and asserts ParseDriverInstances recovers the override byte-for-byte (and null stays null). Guards against a future projection / [JsonIgnore] silently reverting authored resilience policy to tier defaults while hand-built artifact tests stay green. ControlPlane.Tests ConfigComposerTests 6/6 green.
209 lines
9.2 KiB
C#
209 lines
9.2 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
|
|
|
|
public sealed class ConfigComposerTests : ControlPlaneActorTestBase
|
|
{
|
|
/// <summary>Verifies that an empty database produces a stable, reproducible hash.</summary>
|
|
[Fact]
|
|
public async Task Empty_database_produces_stable_hash()
|
|
{
|
|
var f = NewInMemoryDbFactory();
|
|
|
|
await using var db1 = f.CreateDbContext();
|
|
var a1 = await ConfigComposer.SnapshotAndFlattenAsync(db1);
|
|
|
|
await using var db2 = f.CreateDbContext();
|
|
var a2 = await ConfigComposer.SnapshotAndFlattenAsync(db2);
|
|
|
|
a1.RevisionHash.ShouldBe(a2.RevisionHash);
|
|
a1.Blob.ShouldBe(a2.Blob);
|
|
}
|
|
|
|
/// <summary>Verifies that insertion order does not affect the configuration hash.</summary>
|
|
[Fact]
|
|
public async Task Same_rows_in_different_insert_orders_produce_same_hash()
|
|
{
|
|
var name = Guid.NewGuid().ToString("N");
|
|
var f = NewInMemoryDbFactory(name);
|
|
|
|
await using (var db = f.CreateDbContext())
|
|
{
|
|
db.ServerClusters.Add(NewCluster("cluster-a"));
|
|
db.ServerClusters.Add(NewCluster("cluster-b"));
|
|
await db.SaveChangesAsync();
|
|
}
|
|
var hashAB = (await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext())).RevisionHash;
|
|
|
|
// Fresh DB, same rows in reverse insertion order.
|
|
var f2 = NewInMemoryDbFactory();
|
|
await using (var db = f2.CreateDbContext())
|
|
{
|
|
db.ServerClusters.Add(NewCluster("cluster-b"));
|
|
db.ServerClusters.Add(NewCluster("cluster-a"));
|
|
await db.SaveChangesAsync();
|
|
}
|
|
var hashBA = (await ConfigComposer.SnapshotAndFlattenAsync(f2.CreateDbContext())).RevisionHash;
|
|
|
|
hashAB.ShouldBe(hashBA);
|
|
}
|
|
|
|
/// <summary>Verifies that different database configurations produce different hashes.</summary>
|
|
[Fact]
|
|
public async Task Different_data_produces_different_hash()
|
|
{
|
|
var f = NewInMemoryDbFactory();
|
|
await using (var db = f.CreateDbContext())
|
|
{
|
|
db.ServerClusters.Add(NewCluster("cluster-a"));
|
|
await db.SaveChangesAsync();
|
|
}
|
|
var hashA = (await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext())).RevisionHash;
|
|
|
|
await using (var db = f.CreateDbContext())
|
|
{
|
|
db.ServerClusters.Add(NewCluster("cluster-b"));
|
|
await db.SaveChangesAsync();
|
|
}
|
|
var hashAB = (await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext())).RevisionHash;
|
|
|
|
hashAB.ShouldNotBe(hashA);
|
|
}
|
|
|
|
/// <summary>Verifies that the revision hash is a 64-character lowercase hexadecimal string.</summary>
|
|
[Fact]
|
|
public async Task Hash_is_64_lowercase_hex_chars()
|
|
{
|
|
var f = NewInMemoryDbFactory();
|
|
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
|
|
artifact.RevisionHash.Length.ShouldBe(64);
|
|
artifact.RevisionHash.ShouldMatch("^[0-9a-f]{64}$");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that <see cref="ConfigComposer.SnapshotAndFlattenAsync"/> serialises a
|
|
/// <see cref="Device"/>'s <c>HostAddress</c> into the artifact blob and that
|
|
/// <see cref="DeploymentArtifact.ParseComposition(ReadOnlySpan{byte})"/> decodes it back
|
|
/// as the equipment's <see cref="ZB.MOM.WW.OtOpcUa.OpcUaServer.EquipmentNode.DeviceHost"/>
|
|
/// (follow-up E). Guards the real serialize→decode seam: if ConfigComposer's Device
|
|
/// serialisation ever drifted, DeviceHost would silently become null in production
|
|
/// (feature E degrades to a warn-skip) while hand-rolled artifact tests stayed green.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task DeviceHost_survives_ConfigComposer_to_ParseComposition_round_trip()
|
|
{
|
|
var f = NewInMemoryDbFactory();
|
|
await using (var db = f.CreateDbContext())
|
|
{
|
|
db.ServerClusters.Add(NewCluster("c1"));
|
|
db.Namespaces.Add(new Namespace
|
|
{
|
|
NamespaceId = "ns-eq", ClusterId = "c1",
|
|
Kind = NamespaceKind.Equipment, NamespaceUri = "urn:eq",
|
|
});
|
|
db.DriverInstances.Add(new DriverInstance
|
|
{
|
|
DriverInstanceId = "drv-1", ClusterId = "c1", NamespaceId = "ns-eq",
|
|
Name = "Focas", DriverType = "Focas", DriverConfig = "{}",
|
|
});
|
|
db.Devices.Add(new Device
|
|
{
|
|
DeviceId = "dev-1", DriverInstanceId = "drv-1",
|
|
Name = "dev-1", DeviceConfig = "{\"HostAddress\":\"10.9.9.9:8193\"}",
|
|
});
|
|
db.UnsAreas.Add(new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "area-1" });
|
|
db.UnsLines.Add(new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" });
|
|
db.Equipment.Add(new Equipment
|
|
{
|
|
EquipmentId = "eq-1", DriverInstanceId = "drv-1", DeviceId = "dev-1",
|
|
UnsLineId = "line-1", Name = "machine-1", MachineCode = "MACHINE_001",
|
|
});
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
await using var readDb = f.CreateDbContext();
|
|
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(readDb);
|
|
var composition = DeploymentArtifact.ParseComposition(artifact.Blob);
|
|
|
|
var node = composition.EquipmentNodes.ShouldHaveSingleItem();
|
|
node.EquipmentId.ShouldBe("eq-1");
|
|
node.DriverInstanceId.ShouldBe("drv-1");
|
|
node.DeviceId.ShouldBe("dev-1");
|
|
node.DeviceHost.ShouldBe("10.9.9.9:8193");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a <see cref="DriverInstance"/>'s per-instance <c>ResilienceConfig</c> override
|
|
/// survives the real <see cref="ConfigComposer.SnapshotAndFlattenAsync"/> →
|
|
/// <see cref="DeploymentArtifact.ParseDriverInstances(ReadOnlySpan{byte})"/> seam (arch-review
|
|
/// follow-up #10 sub-gap). The composer serialises the whole entity, so the override rides the
|
|
/// artifact into <c>DriverInstanceSpec.ResilienceConfig</c> — which <c>DriverHostActor</c> layers
|
|
/// onto the driver's Polly pipeline. Guards the one leg the hand-rolled
|
|
/// <c>DeploymentArtifactTests.ParseDriverInstances_carries_ResilienceConfig_onto_the_spec</c>
|
|
/// cannot: if the composer ever stopped emitting the column (a projection, a <c>[JsonIgnore]</c>),
|
|
/// authored resilience policy would silently revert to tier defaults in production while the
|
|
/// hand-built artifact tests stayed green.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ResilienceConfig_survives_ConfigComposer_to_ParseDriverInstances_round_trip()
|
|
{
|
|
const string resilienceJson =
|
|
"{\"capabilityPolicies\":{\"Read\":{\"timeoutSeconds\":5,\"retryCount\":7,\"breakerFailureThreshold\":3}}}";
|
|
|
|
var f = NewInMemoryDbFactory();
|
|
await using (var db = f.CreateDbContext())
|
|
{
|
|
db.ServerClusters.Add(NewCluster("c1"));
|
|
db.Namespaces.Add(new Namespace
|
|
{
|
|
NamespaceId = "ns-eq", ClusterId = "c1",
|
|
Kind = NamespaceKind.Equipment, NamespaceUri = "urn:eq",
|
|
});
|
|
db.DriverInstances.Add(new DriverInstance
|
|
{
|
|
DriverInstanceId = "drv-1", ClusterId = "c1", NamespaceId = "ns-eq",
|
|
Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}",
|
|
ResilienceConfig = resilienceJson,
|
|
});
|
|
// A second instance with no override proves the null case rides the same real composer path.
|
|
db.DriverInstances.Add(new DriverInstance
|
|
{
|
|
DriverInstanceId = "drv-2", ClusterId = "c1", NamespaceId = "ns-eq",
|
|
Name = "Modbus2", DriverType = "Modbus", DriverConfig = "{}",
|
|
});
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
await using var readDb = f.CreateDbContext();
|
|
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(readDb);
|
|
var specs = DeploymentArtifact.ParseDriverInstances(artifact.Blob);
|
|
|
|
var withOverride = specs.Single(s => s.DriverInstanceId == "drv-1");
|
|
withOverride.ResilienceConfig.ShouldBe(resilienceJson, "the authored override must ride the artifact byte-for-byte");
|
|
|
|
var withoutOverride = specs.Single(s => s.DriverInstanceId == "drv-2");
|
|
withoutOverride.ResilienceConfig.ShouldBeNull("an unset override stays null ⇒ tier defaults at spawn");
|
|
}
|
|
|
|
private static readonly DateTime FixedTimestamp = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
|
|
private static ServerCluster NewCluster(string id) => new()
|
|
{
|
|
ClusterId = id,
|
|
Name = id,
|
|
Enterprise = "ent",
|
|
Site = "site",
|
|
RedundancyMode = RedundancyMode.None,
|
|
CreatedBy = "test",
|
|
// Pin every timestamp so two harnesses produce byte-identical snapshots when the logical
|
|
// content matches. Production rows get real DateTime.UtcNow — divergence there is correct.
|
|
CreatedAt = FixedTimestamp,
|
|
};
|
|
}
|