b27b5b9cc1
v2-ci / unit-tests (push) Has been cancelled
Every Boolean under Server/ServerCapabilities/HistoryServerCapabilities read false (the SDK default) even though data and event history were served, so a spec-following client skipped HistoryRead entirely. Each backend now declares what it serves through its own interface: - IHistorianDataSource.ReadCapabilities (Data / Events / ServerTimestamps): Gateway Data|Events; SqlServer + QuestDb add ServerTimestamps (they store ServerTimeUtc, the Gateway stamps read time); Null declares None. A reflection guard requires every production source to declare it itself. - IHistorianDataUpdater.Capabilities (required member): SqlServer and QuestDb declare Insert/Replace/Update/DeleteRaw/DeleteAtTime; the Gateway provider has no updater. The node manager writes the flags on every source/updater assignment (AdvertisedHistoryCapabilities), and the same declarations drive dispatch: an update the updater does not declare is BadHistoryOperationUnsupported without reaching it, the HistoryWrite AccessLevel bit follows a non-empty declaration, and event-history notifier promotion follows the Events flag. Event and annotation update flags stay false (nothing dispatches them). Tests: booted-server wire test per backend + Null + a SqlServer->Gateway rewire pairing on one session; dispatch gating unit test; the #801 live gate now asserts the Gateway flags (not run here). Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
167 lines
8.2 KiB
C#
167 lines
8.2 KiB
C#
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
|
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// lmxopcua#881 over a real SDK client session: the Boolean <c>Server/ServerCapabilities/HistoryServerCapabilities</c>
|
|
/// properties follow the wired backend. The data source AND the updater are built through
|
|
/// <see cref="HistorianBackendRegistry"/> exactly as the Host builds them (Program.cs wires
|
|
/// <c>provider.CreateUpdater</c>), so each expectation is what that backend really serves:
|
|
/// <list type="bullet">
|
|
/// <item>Gateway — data + event reads; no HistoryUpdate path (its provider returns no updater); AVEVA keeps no
|
|
/// per-sample server time.</item>
|
|
/// <item>SqlServer / QuestDb — data + event reads; Insert / Replace / Update / DeleteRaw / DeleteAtTime
|
|
/// (QuestDB deletes are tombstones); every sample stores its server time.</item>
|
|
/// <item>None of them — event or annotation updates: the server dispatches none.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
public sealed class HistoryServerCapabilitiesWireTests
|
|
{
|
|
private static readonly (string Name, NodeId Id)[] Flags =
|
|
[
|
|
("AccessHistoryDataCapability", VariableIds.HistoryServerCapabilities_AccessHistoryDataCapability),
|
|
("AccessHistoryEventsCapability", VariableIds.HistoryServerCapabilities_AccessHistoryEventsCapability),
|
|
("ServerTimestampSupported", VariableIds.HistoryServerCapabilities_ServerTimestampSupported),
|
|
("InsertDataCapability", VariableIds.HistoryServerCapabilities_InsertDataCapability),
|
|
("ReplaceDataCapability", VariableIds.HistoryServerCapabilities_ReplaceDataCapability),
|
|
("UpdateDataCapability", VariableIds.HistoryServerCapabilities_UpdateDataCapability),
|
|
("DeleteRawCapability", VariableIds.HistoryServerCapabilities_DeleteRawCapability),
|
|
("DeleteAtTimeCapability", VariableIds.HistoryServerCapabilities_DeleteAtTimeCapability),
|
|
("InsertEventCapability", VariableIds.HistoryServerCapabilities_InsertEventCapability),
|
|
("ReplaceEventCapability", VariableIds.HistoryServerCapabilities_ReplaceEventCapability),
|
|
("UpdateEventCapability", VariableIds.HistoryServerCapabilities_UpdateEventCapability),
|
|
("DeleteEventCapability", VariableIds.HistoryServerCapabilities_DeleteEventCapability),
|
|
("InsertAnnotationCapability", VariableIds.HistoryServerCapabilities_InsertAnnotationCapability),
|
|
];
|
|
|
|
private static readonly string[] ReadOnlyHistory = ["AccessHistoryDataCapability", "AccessHistoryEventsCapability"];
|
|
|
|
private static readonly string[] UpdatableHistory =
|
|
[
|
|
"AccessHistoryDataCapability", "AccessHistoryEventsCapability", "ServerTimestampSupported",
|
|
"InsertDataCapability", "ReplaceDataCapability", "UpdateDataCapability", "DeleteRawCapability", "DeleteAtTimeCapability",
|
|
];
|
|
|
|
/// <summary>The flags expected TRUE per backend; every other flag in <see cref="Flags"/> must read false.</summary>
|
|
private static string[] ExpectedTrue(string backend) => backend == "Gateway" ? ReadOnlyHistory : UpdatableHistory;
|
|
|
|
private static IHistorianBackendProvider Provider(string backend)
|
|
{
|
|
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["ServerHistorian:SqlServer:ConnectionString"] = "Server=localhost;User Id=sa;Password=x;TrustServerCertificate=True",
|
|
["ServerHistorian:QuestDb:ConnectionString"] = "Host=localhost;Port=18812;Username=admin;Password=quest;Database=qdb",
|
|
}).Build();
|
|
var options = new ServerHistorianOptions
|
|
{
|
|
Backend = backend, Enabled = true, Endpoint = "http://localhost:5222", ApiKey = "histgw_id_secret", UseTls = false,
|
|
};
|
|
// Constructing the seams opens no connection; nothing here reaches a database or a gateway.
|
|
return HistorianBackendRegistry.Resolve(options, config);
|
|
}
|
|
|
|
private static void Wire(ServerEventRig rig, string backend)
|
|
{
|
|
var sp = new ServiceCollection().BuildServiceProvider();
|
|
var provider = Provider(backend);
|
|
rig.Nm.HistorianDataSource = provider.CreateDataSource(sp);
|
|
rig.Nm.HistorianDataUpdater = provider.CreateUpdater(sp);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Gateway")]
|
|
[InlineData("SqlServer")]
|
|
[InlineData("QuestDb")]
|
|
public async Task Each_backend_advertises_exactly_what_it_serves(string backend)
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync($"HistCaps{backend}", encrypted: false, ct);
|
|
try
|
|
{
|
|
Wire(rig, backend);
|
|
rig.Enforce(false, [Key()]);
|
|
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
|
|
|
var read = await ReadFlagsAsync(session, ct);
|
|
var expected = ExpectedTrue(backend);
|
|
foreach (var (name, _) in Flags)
|
|
read[name].ShouldBe(expected.Contains(name), $"{backend}: {name}");
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task With_no_historian_wired_every_flag_stays_false()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync("HistCapsNull", encrypted: false, ct);
|
|
try
|
|
{
|
|
// The Host's disabled-historian wiring: the Null source and no updater, assigned explicitly.
|
|
rig.Nm.HistorianDataSource = null!;
|
|
rig.Nm.HistorianDataUpdater = null;
|
|
rig.Enforce(false, [Key()]);
|
|
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
|
|
|
(await ReadFlagsAsync(session, ct)).Values.ShouldAllBe(v => v == false);
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Rewiring_SqlServer_to_Gateway_retracts_the_update_flags_on_the_same_session()
|
|
{
|
|
// Positive/negative pairing on one live session: the same nodes read true under SqlServer and false under the
|
|
// Gateway, so neither expectation can pass on a server that never writes the properties.
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync("HistCapsRewire", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.Enforce(false, [Key()]);
|
|
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
|
|
|
Wire(rig, "SqlServer");
|
|
var sql = await ReadFlagsAsync(session, ct);
|
|
sql["InsertDataCapability"].ShouldBeTrue();
|
|
sql["DeleteRawCapability"].ShouldBeTrue();
|
|
sql["ServerTimestampSupported"].ShouldBeTrue();
|
|
sql["AccessHistoryDataCapability"].ShouldBeTrue();
|
|
|
|
Wire(rig, "Gateway");
|
|
var gateway = await ReadFlagsAsync(session, ct);
|
|
gateway["InsertDataCapability"].ShouldBeFalse();
|
|
gateway["DeleteRawCapability"].ShouldBeFalse();
|
|
gateway["ServerTimestampSupported"].ShouldBeFalse();
|
|
gateway["AccessHistoryDataCapability"].ShouldBeTrue("read access is not an update flag");
|
|
|
|
rig.Nm.HistorianDataSource = null!;
|
|
rig.Nm.HistorianDataUpdater = null;
|
|
(await ReadFlagsAsync(session, ct))["AccessHistoryDataCapability"].ShouldBeFalse("unwiring retracts read access too");
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
private static async Task<Dictionary<string, bool>> ReadFlagsAsync(ISession session, CancellationToken ct)
|
|
{
|
|
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither,
|
|
Flags.Select(f => new ReadValueId { NodeId = f.Id, AttributeId = Attributes.Value }).ToArray(), ct);
|
|
var results = response.Results.ToList();
|
|
var flags = new Dictionary<string, bool>(StringComparer.Ordinal);
|
|
for (var i = 0; i < Flags.Length; i++)
|
|
{
|
|
StatusCode.IsGood(results[i].StatusCode).ShouldBeTrue($"{Flags[i].Name}: {results[i].StatusCode}");
|
|
flags[Flags[i].Name] = results[i].ClrValue() is true;
|
|
}
|
|
|
|
return flags;
|
|
}
|
|
}
|