Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ScopePathIndexBuilderTests.cs
Joseph Doherty 1be0fb5a29 Phase 6.2 Stream C.12 — lock in ScopePathIndexBuilder semantics with tests
Closes task #123 (partial — builder semantics unit-tested; production
wiring is the new task #133).

ScopePathIndexBuilder + NodeScopeResolver indexed mode already exist —
they produce a full Cluster → Namespace → UnsArea → UnsLine → Equipment
→ Tag scope from the published generation's config rows. What was
missing: unit coverage of the Build semantics (the only consumers were
compile-time references) + explicit acknowledgement in the readiness
doc that the gate/resolver aren't yet wired into Program.cs.

Tests — 6 cases in ScopePathIndexBuilderTests.cs:
- Well-formed content emits full hierarchy.
- Tags with null EquipmentId skipped (SystemPlatform-namespace fallback).
- Tags with broken Equipment FK skipped (publish-time validation
  should have caught; builder is defensive).
- Equipment with broken Line FK skipped.
- Duplicate TagConfig throws InvalidOperationException.
- Resolver with index returns full-path scope; un-indexed ref falls
  through to cluster-only scope (pre-ADR-001 behaviour preserved).

Server.Tests 277 → 283.

Critical follow-up (task #133): Program.cs still constructs
OpcUaApplicationHost WITHOUT authzGate or scopeResolver, so all six
dispatch-layer gates (Read, Write, HistoryRead, Browse,
CreateMonitoredItems, Call) are currently inert in production. Wiring
them up — load NodeAcl + EquipmentNamespaceContent at bootstrap,
construct gate + resolver, pass into OpcUaApplicationHost, rebind on
generation refresh — is the last Phase 6.2 GA blocker.

Docs: v2-release-readiness.md Phase 6.2 Stream C hardening list marks
the scope-resolution bullet struck-through with a close-out note that
calls out the gate-inert-in-production gap + task #133.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:28:19 -04:00

149 lines
5.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.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
/// <summary>
/// Unit tests for <see cref="ScopePathIndexBuilder"/> — the ADR-001 Task B builder that
/// produces the full-path <see cref="NodeScope"/> index consumed by
/// <see cref="NodeScopeResolver"/> in its indexed mode.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScopePathIndexBuilderTests
{
[Fact]
public void Build_emits_full_hierarchy_for_well_formed_content()
{
var index = ScopePathIndexBuilder.Build("c1", "ns-eq", Content(
areas: [Area("area1")],
lines: [Line("line1", "area1")],
equipment: [Equip("eq1", "line1")],
tags: [TagRow("tag1", "eq1", tagConfig: "Eq1/Speed")]));
index.Count.ShouldBe(1);
var scope = index["Eq1/Speed"];
scope.ClusterId.ShouldBe("c1");
scope.NamespaceId.ShouldBe("ns-eq");
scope.UnsAreaId.ShouldBe("area1");
scope.UnsLineId.ShouldBe("line1");
scope.EquipmentId.ShouldBe("eq1");
scope.TagId.ShouldBe("Eq1/Speed");
scope.Kind.ShouldBe(NodeHierarchyKind.Equipment);
}
[Fact]
public void Build_skips_tags_with_null_EquipmentId()
{
// SystemPlatform-namespace tags (decision #110) — the cluster-only resolver
// fallback handles them; no index entry needed.
var index = ScopePathIndexBuilder.Build("c1", "ns-sp", Content(
tags: [TagRow("t", equipmentId: null, tagConfig: "Galaxy.Object.Attr")]));
index.Count.ShouldBe(0);
}
[Fact]
public void Build_skips_tags_with_broken_Equipment_FK()
{
// Tag references a missing Equipment row. sp_ValidateDraft should have caught this
// at publish; builder skips rather than crashes so startup stays bootable.
var index = ScopePathIndexBuilder.Build("c1", "ns", Content(
areas: [Area("area1")],
lines: [Line("line1", "area1")],
tags: [TagRow("t", "missing-eq", "missing/Speed")]));
index.Count.ShouldBe(0);
}
[Fact]
public void Build_skips_equipment_with_broken_line_FK()
{
var index = ScopePathIndexBuilder.Build("c1", "ns", Content(
areas: [Area("area1")],
lines: [], // no lines — equipment's UnsLineId misses
equipment: [Equip("eq1", "missing")],
tags: [TagRow("t", "eq1", "E/S")]));
index.Count.ShouldBe(0);
}
[Fact]
public void Build_throws_on_duplicate_TagConfig()
{
var ex = Should.Throw<InvalidOperationException>(() =>
ScopePathIndexBuilder.Build("c1", "ns", Content(
areas: [Area("area1")],
lines: [Line("line1", "area1")],
equipment: [Equip("eq1", "line1")],
tags:
[
TagRow("t1", "eq1", "E/DUP"),
TagRow("t2", "eq1", "E/DUP"),
])));
ex.Message.ShouldContain("Duplicate");
ex.Message.ShouldContain("E/DUP");
}
[Fact]
public void Resolver_with_index_returns_full_path_scope()
{
var index = ScopePathIndexBuilder.Build("c1", "ns", Content(
areas: [Area("area1")],
lines: [Line("line1", "area1")],
equipment: [Equip("eq1", "line1")],
tags: [TagRow("t", "eq1", "E/Speed")]));
var resolver = new NodeScopeResolver("c1", index);
var resolved = resolver.Resolve("E/Speed");
resolved.UnsAreaId.ShouldBe("area1");
resolved.UnsLineId.ShouldBe("line1");
resolved.EquipmentId.ShouldBe("eq1");
// Un-indexed ref falls through to cluster-only scope — pre-ADR-001 behaviour preserved.
var fallback = resolver.Resolve("Galaxy.Object.Attr");
fallback.ClusterId.ShouldBe("c1");
fallback.TagId.ShouldBe("Galaxy.Object.Attr");
fallback.UnsAreaId.ShouldBeNull();
}
// ---- fixture helpers ---------------------------------------------------
private static EquipmentNamespaceContent Content(
IReadOnlyList<UnsArea>? areas = null,
IReadOnlyList<UnsLine>? lines = null,
IReadOnlyList<Equipment>? equipment = null,
IReadOnlyList<Tag>? tags = null) =>
new(areas ?? [], lines ?? [], equipment ?? [], tags ?? []);
private static UnsArea Area(string id) => new()
{
UnsAreaId = id, ClusterId = "c1", Name = $"Area {id}",
};
private static UnsLine Line(string id, string areaId) => new()
{
UnsLineId = id, UnsAreaId = areaId, Name = $"Line {id}",
};
private static Equipment Equip(string id, string lineId) => new()
{
EquipmentId = id, UnsLineId = lineId, DriverInstanceId = "drv",
Name = $"Eq {id}", MachineCode = $"M{id}", ZTag = id,
};
private static Tag TagRow(string id, string? equipmentId, string tagConfig) => new()
{
TagId = id, EquipmentId = equipmentId,
DriverInstanceId = "drv",
Name = id, DataType = "Int32",
AccessLevel = TagAccessLevel.ReadWrite,
TagConfig = tagConfig,
};
}