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;
///
/// lmxopcua#881 over a real SDK client session: the Boolean Server/ServerCapabilities/HistoryServerCapabilities
/// properties follow the wired backend. The data source AND the updater are built through
/// exactly as the Host builds them (Program.cs wires
/// provider.CreateUpdater), so each expectation is what that backend really serves:
///
/// - Gateway — data + event reads; no HistoryUpdate path (its provider returns no updater); AVEVA keeps no
/// per-sample server time.
/// - SqlServer / QuestDb — data + event reads; Insert / Replace / Update / DeleteRaw / DeleteAtTime
/// (QuestDB deletes are tombstones); every sample stores its server time.
/// - None of them — event or annotation updates: the server dispatches none.
///
///
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",
];
/// The flags expected TRUE per backend; every other flag in must read false.
private static string[] ExpectedTrue(string backend) => backend == "Gateway" ? ReadOnlyHistory : UpdatableHistory;
private static IHistorianBackendProvider Provider(string backend)
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary
{
["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> 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(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;
}
}