73e74e270d
StartDeployment read the whole config DB twice with no transaction — DraftSnapshotFactory for the validator, then ConfigComposer for the artifact, plus clusters/nodes and Script.SourceCode again in between. An AdminUI save landing between the two shipped an artifact the DraftValidator never saw. - DraftSnapshot carries ServerClusters, ClusterNodes and ApiKeys; DraftSnapshotFactory reads every table inside one SNAPSHOT transaction on a relational provider (no-op on InMemory or under an ambient transaction). - ConfigComposer.Compose(DraftSnapshot) is pure over the snapshot (ordinal-sorted keys); SnapshotAndFlattenAsync(db) delegates to it, byte-identical. - AdminOperationsActor validates topology, estimates compile cost and composes the artifact from the one snapshot it holds. - Migration EnableSnapshotIsolation: ALTER DATABASE CURRENT SET ALLOW_SNAPSHOT_ISOLATION ON (suppressTransaction; no model change). Tests: the SQL Server snapshot read ignores a row committed mid-read (positive control: a later read sees it); an edit injected between the Equipment and Tags reads is absent from the shipped artifact and rejected by the next deploy; Compose(snapshot) == SnapshotAndFlattenAsync(db). Refs #733 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
169 lines
6.6 KiB
C#
169 lines
6.6 KiB
C#
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.Validation;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
|
|
|
/// <summary>
|
|
/// Verifies <see cref="DraftSnapshotFactory.FromConfigDbAsync"/> materialises a
|
|
/// <see cref="DraftSnapshot"/> from the live config DB whose Tag/VirtualTag rows feed the
|
|
/// equipment-signal collision rule — the one rule wired into the deploy gate (Task 3).
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class DraftSnapshotFactoryTests : IDisposable
|
|
{
|
|
private readonly OtOpcUaConfigDbContext _db;
|
|
|
|
/// <summary>Initializes a new instance with an isolated in-memory config DB.</summary>
|
|
public DraftSnapshotFactoryTests()
|
|
{
|
|
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
|
.UseInMemoryDatabase($"draft-snapshot-{Guid.NewGuid():N}")
|
|
.Options;
|
|
_db = new OtOpcUaConfigDbContext(options);
|
|
}
|
|
|
|
/// <summary>Disposes the database context.</summary>
|
|
public void Dispose() => _db.Dispose();
|
|
|
|
/// <summary>Seeds an Equipment plus a raw Tag and a VirtualTag; the snapshot must carry both
|
|
/// signal collections. v3: Tags are raw-only, so the equipment-signal collision rule now operates
|
|
/// on VirtualTags — two VirtualTags sharing (EquipmentId, Name) must surface the collision.</summary>
|
|
[Fact]
|
|
public async Task FromConfigDb_populates_Tags_and_VirtualTags_and_surfaces_collision()
|
|
{
|
|
SeedEquipment("eq-1");
|
|
_db.Tags.Add(BuildTag(name: "speed"));
|
|
_db.VirtualTags.Add(BuildVirtualTag(equipmentId: "eq-1", name: "speed", suffix: "a"));
|
|
_db.VirtualTags.Add(BuildVirtualTag(equipmentId: "eq-1", name: "speed", suffix: "b"));
|
|
await _db.SaveChangesAsync();
|
|
|
|
var snapshot = await DraftSnapshotFactory.FromConfigDbAsync(_db);
|
|
|
|
snapshot.Tags.Count.ShouldBe(1);
|
|
snapshot.VirtualTags.Count.ShouldBe(2);
|
|
DraftValidator.Validate(snapshot).ShouldContain(e => e.Code == "UnsEffectiveNameCollision");
|
|
}
|
|
|
|
/// <summary>Distinct VirtualTag names under the same equipment do not collide, so the snapshot
|
|
/// validates clean of the collision code.</summary>
|
|
[Fact]
|
|
public async Task FromConfigDb_no_collision_when_names_differ()
|
|
{
|
|
SeedEquipment("eq-1");
|
|
_db.Tags.Add(BuildTag(name: "speed"));
|
|
_db.VirtualTags.Add(BuildVirtualTag(equipmentId: "eq-1", name: "temperature"));
|
|
await _db.SaveChangesAsync();
|
|
|
|
var snapshot = await DraftSnapshotFactory.FromConfigDbAsync(_db);
|
|
|
|
snapshot.Tags.Count.ShouldBe(1);
|
|
snapshot.VirtualTags.Count.ShouldBe(1);
|
|
DraftValidator.Validate(snapshot).ShouldNotContain(e => e.Code == "UnsEffectiveNameCollision");
|
|
}
|
|
|
|
/// <summary>Seeds one active and one released reservation for the same equipment context;
|
|
/// only the active row (ReleasedAt == null) should appear in the snapshot.</summary>
|
|
[Fact]
|
|
public async Task FromConfigDb_ActiveReservations_excludes_released_rows()
|
|
{
|
|
var equipmentUuid = Guid.NewGuid();
|
|
|
|
_db.ExternalIdReservations.Add(new ExternalIdReservation
|
|
{
|
|
ReservationId = Guid.NewGuid(),
|
|
Kind = ReservationKind.ZTag,
|
|
Value = "ZT-001",
|
|
EquipmentUuid = equipmentUuid,
|
|
ClusterId = "cluster-a",
|
|
FirstPublishedBy = "test",
|
|
ReleasedAt = null, // active
|
|
});
|
|
_db.ExternalIdReservations.Add(new ExternalIdReservation
|
|
{
|
|
ReservationId = Guid.NewGuid(),
|
|
Kind = ReservationKind.ZTag,
|
|
Value = "ZT-002",
|
|
EquipmentUuid = equipmentUuid,
|
|
ClusterId = "cluster-a",
|
|
FirstPublishedBy = "test",
|
|
ReleasedAt = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), // released
|
|
ReleasedBy = "test",
|
|
ReleaseReason = "retired",
|
|
});
|
|
await _db.SaveChangesAsync();
|
|
|
|
var snapshot = await DraftSnapshotFactory.FromConfigDbAsync(_db);
|
|
|
|
snapshot.ActiveReservations.Count.ShouldBe(1);
|
|
snapshot.ActiveReservations[0].Value.ShouldBe("ZT-001");
|
|
snapshot.ActiveReservations[0].ReleasedAt.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// R03:3.2 — the deploy path validates AND composes from ONE snapshot, so the tables the composer
|
|
/// needs beyond the validator's set (clusters, nodes, API keys) must ride the same read.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task FromConfigDb_carries_clusters_nodes_and_api_keys_for_the_composer()
|
|
{
|
|
_db.ServerClusters.Add(new ServerCluster
|
|
{
|
|
ClusterId = "SITE-A", Name = "Site A", Enterprise = "zb", Site = "site-a",
|
|
NodeCount = 1, RedundancyMode = RedundancyMode.None, CreatedBy = "test",
|
|
});
|
|
_db.ClusterNodes.Add(new ClusterNode
|
|
{
|
|
NodeId = "site-a-1:4053", ClusterId = "SITE-A", Host = "site-a-1",
|
|
ApplicationUri = "urn:OtOpcUa:site-a-1", Enabled = true, CreatedBy = "test",
|
|
});
|
|
_db.ApiKeys.Add(new ApiKey
|
|
{
|
|
ApiKeyId = "k1", Name = "scada", SecretHash = "hash", Principal = "scadabridge", Enabled = true,
|
|
});
|
|
await _db.SaveChangesAsync();
|
|
|
|
var snapshot = await DraftSnapshotFactory.FromConfigDbAsync(_db);
|
|
|
|
snapshot.ServerClusters.ShouldHaveSingleItem().ClusterId.ShouldBe("SITE-A");
|
|
snapshot.ClusterNodes.ShouldHaveSingleItem().NodeId.ShouldBe("site-a-1:4053");
|
|
snapshot.ApiKeys.ShouldHaveSingleItem().ApiKeyId.ShouldBe("k1");
|
|
}
|
|
|
|
private void SeedEquipment(string equipmentId)
|
|
{
|
|
var uuid = Guid.NewGuid();
|
|
_db.Equipment.Add(new Equipment
|
|
{
|
|
EquipmentUuid = uuid,
|
|
EquipmentId = equipmentId,
|
|
Name = "eq",
|
|
UnsLineId = "line-a",
|
|
MachineCode = "m",
|
|
});
|
|
}
|
|
|
|
// v3: Tags are raw-only (DeviceId FK, no EquipmentId/DriverInstanceId).
|
|
private static Tag BuildTag(string name) => new()
|
|
{
|
|
TagId = $"tag-{name}",
|
|
DeviceId = "dev-1",
|
|
Name = name,
|
|
DataType = "Float",
|
|
AccessLevel = TagAccessLevel.Read,
|
|
TagConfig = "{}",
|
|
};
|
|
|
|
private static VirtualTag BuildVirtualTag(string equipmentId, string name, string suffix = "") => new()
|
|
{
|
|
VirtualTagId = $"vtag-{name}{suffix}",
|
|
EquipmentId = equipmentId,
|
|
Name = name,
|
|
DataType = "Float",
|
|
ScriptId = "s-1",
|
|
};
|
|
}
|