using AVEVA.Historian.Client.Grpc; using AVEVA.Historian.Client.Models; using AVEVA.Historian.Client.Wcf; using Google.Protobuf; using ArchestrA.Grpc.Contract.Retrieval; using GrpcHistory = ArchestrA.Grpc.Contract.History; namespace AVEVA.Historian.Client.Tests; /// /// Unit coverage for the 2023 R2 RemoteGrpc transport — the parts that do not require a live /// server: channel address/port resolution, metadata, transport routing, and the invariant that /// gRPC request messages carry the same native byte buffers the WCF path uses. /// public sealed class HistorianGrpcTransportTests { private static HistorianClientOptions Options( string host = "histserver", int port = HistorianClientOptions.DefaultPort, bool tls = false, string? dnsIdentity = null, bool compression = false) => new() { Host = host, Port = port, Transport = HistorianTransport.RemoteGrpc, GrpcUseTls = tls, ServerDnsIdentity = dnsIdentity, Compression = compression, IntegratedSecurity = true }; [Fact] public void ResolvePort_DefaultWcfPort_SubstitutesGrpcDefault() { Assert.Equal(HistorianClientOptions.DefaultGrpcPort, HistorianGrpcChannelFactory.ResolvePort(Options(port: HistorianClientOptions.DefaultPort))); } [Fact] public void ResolvePort_ExplicitPort_IsHonoured() { Assert.Equal(443, HistorianGrpcChannelFactory.ResolvePort(Options(port: 443))); } [Fact] public void ResolveAddress_Plaintext_UsesHttpAndHost() { Assert.Equal("http://histserver:32565", HistorianGrpcChannelFactory.ResolveAddress(Options())); } [Fact] public void ResolveAddress_Tls_UsesHttpsAndHostWhenNoDnsIdentity() { Assert.Equal("https://histserver:32565", HistorianGrpcChannelFactory.ResolveAddress(Options(tls: true))); } [Fact] public void ResolveAddress_Tls_PrefersDnsIdentityForCertMatch() { string address = HistorianGrpcChannelFactory.ResolveAddress(Options(host: "10.0.0.5", tls: true, dnsIdentity: "localhost")); Assert.Equal("https://localhost:32565", address); } [Fact] public void Create_CompressionDisabled_EmitsNoEncodingHeader() { using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(Options(compression: false)); Assert.DoesNotContain(connection.Metadata, e => e.Key == "grpc-internal-encoding-request"); } [Fact] public void Create_CompressionEnabled_AdvertisesGzipRequestEncoding() { using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(Options(compression: true)); global::Grpc.Core.Metadata.Entry entry = Assert.Single(connection.Metadata, e => e.Key == "grpc-internal-encoding-request"); Assert.Equal("gzip", entry.Value); } [Fact] public void StartQueryRequest_CarriesNativeDataQueryBufferUnchanged() { // The gRPC envelope must wrap the exact bytes the WCF StartQuery2 path sends, so the // already-reverse-engineered DataQueryRequest serializer is reused verbatim. HistorianDataQueryRequest request = HistorianWcfReadOrchestrator.BuildDataQueryRequest( "Tag.Counter", new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc), 100); byte[] nativeBuffer = HistorianDataQueryProtocol.SerializeFullHistoryRequest(request); var message = new StartQueryRequest { UiHandle = 7, UiQueryRequestType = HistorianDataQueryProtocol.QueryRequestTypeData, BtRequestBuffer = ByteString.CopyFrom(nativeBuffer) }; // Round-trip through protobuf and confirm the native buffer survives byte-for-byte. byte[] wire = message.ToByteArray(); var decoded = StartQueryRequest.Parser.ParseFrom(wire); Assert.Equal(nativeBuffer, decoded.BtRequestBuffer.ToByteArray()); Assert.Equal(7u, decoded.UiHandle); Assert.Equal((uint)HistorianDataQueryProtocol.QueryRequestTypeData, decoded.UiQueryRequestType); } [Fact] public void InterfaceVersionResponses_ExposeErrorAndVersion_AsProbeExpects() { // R0.4 ProbeAsync reads uiError/uiVersion off each service's GetInterfaceVersion response. // Pin that field mapping (success = uiError 0 + uiVersion > 0) via a protobuf round-trip. var history = GrpcHistory.GetInterfaceVersionResponse.Parser.ParseFrom( new GrpcHistory.GetInterfaceVersionResponse { UiError = 0, UiVersion = 12 }.ToByteArray()); var retrieval = GetRetrievalInterfaceVersionResponse.Parser.ParseFrom( new GetRetrievalInterfaceVersionResponse { UiError = 0, UiVersion = 4 }.ToByteArray()); Assert.Equal(0u, history.UiError); Assert.Equal(12u, history.UiVersion); Assert.Equal(0u, retrieval.UiError); Assert.Equal(4u, retrieval.UiVersion); } [Fact] public void GetSystemParameterMessages_CarryHandleNameAndValue_AsStatusClientExpects() { // R0.3 sends {uiHandle, strParameterName} and reads strParameterValue when status succeeds. var request = ArchestrA.Grpc.Contract.Status.GetSystemParameterRequest.Parser.ParseFrom( new ArchestrA.Grpc.Contract.Status.GetSystemParameterRequest { UiHandle = 9, StrParameterName = "HistorianVersion" }.ToByteArray()); Assert.Equal(9u, request.UiHandle); Assert.Equal("HistorianVersion", request.StrParameterName); var response = ArchestrA.Grpc.Contract.Status.GetSystemParameterResponse.Parser.ParseFrom( new ArchestrA.Grpc.Contract.Status.GetSystemParameterResponse { Status = new ArchestrA.Grpc.Contract.RequestStatus.Status { BSuccess = true }, StrParameterValue = "20.0.000" }.ToByteArray()); Assert.True(response.Status.BSuccess); Assert.Equal("20.0.000", response.StrParameterValue); } [Fact] public void GetSystemTimeZoneNameMessages_CarryHandleAndValue_AsStatusClientExpects() { // R1.3 sends {uiHandle} and reads strSystemTimeZoneName when status succeeds — no buffer. var request = ArchestrA.Grpc.Contract.Status.GetSystemTimeZoneNameRequest.Parser.ParseFrom( new ArchestrA.Grpc.Contract.Status.GetSystemTimeZoneNameRequest { UiHandle = 11 }.ToByteArray()); Assert.Equal(11u, request.UiHandle); var response = ArchestrA.Grpc.Contract.Status.GetSystemTimeZoneNameResponse.Parser.ParseFrom( new ArchestrA.Grpc.Contract.Status.GetSystemTimeZoneNameResponse { Status = new ArchestrA.Grpc.Contract.RequestStatus.Status { BSuccess = true }, StrSystemTimeZoneName = "Eastern Daylight Time" }.ToByteArray()); Assert.True(response.Status.BSuccess); Assert.Equal("Eastern Daylight Time", response.StrSystemTimeZoneName); } [Fact] public async Task GetServerTimeZoneAsync_OnNonGrpcTransport_ThrowsEvidenceMissing() { // The 2020 WCF GetSystemTimeZoneName is a client-side stub (empty value); R1.3 only has an // evidence-backed value on the gRPC front door, so the non-gRPC path must fail closed. await using var client = new HistorianClient(new HistorianClientOptions { Host = "histserver", Transport = HistorianTransport.LocalPipe, IntegratedSecurity = true }); await Assert.ThrowsAsync(() => client.GetServerTimeZoneAsync()); } [Fact] public void BuildTagNamesBuffer_EncodesCountThenLengthPrefixedUtf16Names() { // R0.2 request framing: uint count + per-name(uint charCount + UTF-16LE). Golden bytes. byte[] buffer = AVEVA.Historian.Client.Grpc.HistorianGrpcTagClient.BuildTagNamesBuffer(["AB", "C"]); byte[] expected = [ 0x02, 0x00, 0x00, 0x00, // count = 2 0x02, 0x00, 0x00, 0x00, // "AB" char count = 2 0x41, 0x00, 0x42, 0x00, // 'A','B' UTF-16LE 0x01, 0x00, 0x00, 0x00, // "C" char count = 1 0x43, 0x00 // 'C' UTF-16LE ]; Assert.Equal(expected, buffer); } [Fact] public void BuildQueryTagRequest_EncodesMarkerVersionTypeStartCount() { // R0.1 QueryTag paging request: u16 0x6752 + u16 1 + u16 queryType + u32 startIndex + u32 count. byte[] buffer = AVEVA.Historian.Client.Grpc.HistorianGrpcTagClient.BuildQueryTagRequest(1, 0, 50); byte[] expected = [ 0x52, 0x67, // marker 0x6752 0x01, 0x00, // version 1 0x01, 0x00, // queryType 1 (names) 0x00, 0x00, 0x00, 0x00, // startIndex 0 0x32, 0x00, 0x00, 0x00 // count 50 ]; Assert.Equal(expected, buffer); } [Theory] [InlineData("*", "")] [InlineData("", "")] [InlineData("Sys*", "startswith(TagName,'Sys')")] [InlineData("*Total", "endswith(TagName,'Total')")] [InlineData("*Alarm*", "contains(TagName,'Alarm')")] [InlineData("Exact.Tag", "TagName eq 'Exact.Tag'")] [InlineData("Pre*Suf", "startswith(TagName,'Pre') and endswith(TagName,'Suf')")] [InlineData("O'Brien*", "startswith(TagName,'O''Brien')")] public void GlobToODataFilter_TranslatesWildcards(string glob, string expected) { Assert.Equal(expected, AVEVA.Historian.Client.Grpc.HistorianGrpcTagClient.GlobToODataFilter(glob)); } [Fact] public void OpenConnectionRequest_CarriesNativeOpen2BufferUnchanged() { byte[] open2 = HistorianNativeHandshake.BuildOpenConnection3Request( "histserver", Guid.NewGuid(), HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode); var message = new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2) }; var decoded = GrpcHistory.OpenConnectionRequest.Parser.ParseFrom(message.ToByteArray()); Assert.Equal(open2, decoded.BtConnectionRequest.ToByteArray()); } }