namespace AVEVA.Historian.Client.Tests; public sealed class HistorianClientIntegrationTests { [Fact] public async Task ProbeAsync_ReturnsTrueForConfiguredHistorian() { string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST"); if (string.IsNullOrWhiteSpace(host)) { return; } int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_PORT"), out int parsedPort) ? parsedPort : HistorianClientOptions.DefaultPort; HistorianClient client = new(new HistorianClientOptions { Host = host, Port = port }); Assert.True(await client.ProbeAsync(CancellationToken.None)); } [Fact] public async Task BrowseTagNamesAsync_ReturnsConfiguredTestTag() { string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST"); string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG"); string? filter = Environment.GetEnvironmentVariable("HISTORIAN_TAG_FILTER") ?? testTag; if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || string.IsNullOrWhiteSpace(filter)) { return; } int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_PORT"), out int parsedPort) ? parsedPort : HistorianClientOptions.DefaultPort; HistorianClient client = new(new HistorianClientOptions { Host = host, Port = port, IntegratedSecurity = true, UserName = Environment.GetEnvironmentVariable("HISTORIAN_USER") ?? string.Empty, Password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD") ?? string.Empty }); List tagNames = []; await foreach (string tagName in client.BrowseTagNamesAsync(filter, CancellationToken.None)) { tagNames.Add(tagName); } Assert.Contains(testTag, tagNames); } [Fact] public async Task ReadRawAsync_AgainstLocalHistorian_ReturnsAtLeastOneRow() { string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST"); string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG"); if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag)) { return; } if (!string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)) { // The managed read flow currently only supports the LocalPipe transport. return; } if (!OperatingSystem.IsWindows()) { return; } HistorianClient client = new(new HistorianClientOptions { Host = host, IntegratedSecurity = true, Transport = HistorianTransport.LocalPipe }); DateTime endUtc = DateTime.UtcNow; DateTime startUtc = endUtc - TimeSpan.FromDays(7); List samples = []; await foreach (AVEVA.Historian.Client.Models.HistorianSample sample in client.ReadRawAsync(testTag, startUtc, endUtc, maxValues: 8, CancellationToken.None)) { samples.Add(sample); } Assert.NotEmpty(samples); Assert.All(samples, s => Assert.Equal(testTag, s.TagName)); } [Fact] public async Task ReadAggregateAsync_AgainstLocalHistorian_ReturnsTimeWeightedAverageRows() { string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST"); string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG"); if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag)) { return; } if (!string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows()) { return; } HistorianClient client = new(new HistorianClientOptions { Host = host, IntegratedSecurity = true, Transport = HistorianTransport.LocalPipe }); DateTime endUtc = DateTime.UtcNow; DateTime startUtc = endUtc - TimeSpan.FromMinutes(10); List samples = []; await foreach (AVEVA.Historian.Client.Models.HistorianAggregateSample sample in client.ReadAggregateAsync( testTag, startUtc, endUtc, AVEVA.Historian.Client.Models.RetrievalMode.TimeWeightedAverage, TimeSpan.FromMinutes(1), CancellationToken.None)) { samples.Add(sample); } Assert.NotEmpty(samples); Assert.All(samples, s => Assert.Equal(testTag, s.TagName)); Assert.All(samples, s => Assert.Equal(AVEVA.Historian.Client.Models.RetrievalMode.TimeWeightedAverage, s.RetrievalMode)); } // Verifies a previously-unmapped RetrievalMode (one of the 11 modes that prior to // 2026-05-04 threw ProtocolEvidenceMissingException). MinimumWithTime → QueryType=6 // exercises the "QueryType is the native enum ordinal" mapping against the live server. [Theory] [InlineData(AVEVA.Historian.Client.Models.RetrievalMode.MinimumWithTime)] [InlineData(AVEVA.Historian.Client.Models.RetrievalMode.MaximumWithTime)] [InlineData(AVEVA.Historian.Client.Models.RetrievalMode.BestFit)] public async Task ReadAggregateAsync_AgainstLocalHistorian_AcceptsPreviouslyUnmappedRetrievalMode( AVEVA.Historian.Client.Models.RetrievalMode mode) { string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST"); string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG"); if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows()) { return; } HistorianClient client = new(new HistorianClientOptions { Host = host, IntegratedSecurity = true, Transport = HistorianTransport.LocalPipe }); DateTime endUtc = DateTime.UtcNow; DateTime startUtc = endUtc - TimeSpan.FromMinutes(10); List samples = []; await foreach (AVEVA.Historian.Client.Models.HistorianAggregateSample s in client.ReadAggregateAsync( testTag, startUtc, endUtc, mode, TimeSpan.FromMinutes(2), CancellationToken.None)) { samples.Add(s); } // Server should accept the request without error. Even if no rows come back // (unlikely for a 10-minute window on a steadily-counting tag), the absence of an // exception proves the QueryType byte was accepted. Assert.All(samples, s => Assert.Equal(mode, s.RetrievalMode)); } [Fact] public async Task ReadAtTimeAsync_AgainstLocalHistorian_ReturnsRequestedTimestamps() { string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST"); string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG"); if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag)) { return; } if (!string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows()) { return; } HistorianClient client = new(new HistorianClientOptions { Host = host, IntegratedSecurity = true, Transport = HistorianTransport.LocalPipe }); DateTime nowUtc = DateTime.UtcNow; DateTime[] timestamps = [ nowUtc - TimeSpan.FromMinutes(5), nowUtc - TimeSpan.FromMinutes(2), nowUtc - TimeSpan.FromMinutes(1) ]; IReadOnlyList samples = await client.ReadAtTimeAsync(testTag, timestamps, CancellationToken.None); Assert.NotEmpty(samples); Assert.All(samples, s => Assert.Equal(testTag, s.TagName)); } [Fact] public async Task ReadEventsAsync_AgainstLocalHistorian_DoesNotThrow() { string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST"); if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows()) { return; } HistorianClient client = new(new HistorianClientOptions { Host = host, IntegratedSecurity = true, Transport = HistorianTransport.LocalPipe }); DateTime endUtc = DateTime.UtcNow; DateTime startUtc = endUtc - TimeSpan.FromDays(7); // The event-row WCF wire format is not yet decoded; this test verifies the chain // (ValCl + Open2 + Retr.IsOriginalAllowed + Retr.StartEventQuery) reaches the server // without throwing. An empty event list is acceptable until row parsing is wired. List events = []; await foreach (AVEVA.Historian.Client.Models.HistorianEvent evt in client.ReadEventsAsync(startUtc, endUtc, CancellationToken.None)) { events.Add(evt); } Assert.NotNull(events); } [Fact] public async Task GetSystemParameterAsync_AgainstLocalHistorian_ReturnsHistorianVersion() { string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST"); if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows()) { return; } HistorianClient client = new(new HistorianClientOptions { Host = host, IntegratedSecurity = true, Transport = HistorianTransport.LocalPipe }); string? value = await client.GetSystemParameterAsync("HistorianVersion", CancellationToken.None); // The server returns a non-empty version string for the documented HistorianVersion parameter. Assert.False(string.IsNullOrWhiteSpace(value)); } [Fact] public async Task GetConnectionStatusAsync_AgainstLocalHistorian_ReportsConnectedToServer() { string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST"); if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows()) { return; } HistorianClient client = new(new HistorianClientOptions { Host = host, IntegratedSecurity = true, Transport = HistorianTransport.LocalPipe }); AVEVA.Historian.Client.Models.HistorianConnectionStatus status = await client.GetConnectionStatusAsync(CancellationToken.None); Assert.True(status.ConnectedToServer); Assert.False(status.ErrorOccurred); Assert.False(status.Pending); Assert.Equal(host, status.ServerName); } [Fact] public async Task GetStoreForwardStatusAsync_AgainstLocalHistorian_ReturnsDefaults() { string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST"); if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows()) { return; } HistorianClient client = new(new HistorianClientOptions { Host = host, IntegratedSecurity = true, Transport = HistorianTransport.LocalPipe }); AVEVA.Historian.Client.Models.HistorianStoreForwardStatus status = await client.GetStoreForwardStatusAsync(CancellationToken.None); // The synthesized status returns defaults — no store-forward sidecar to probe in this build. Assert.False(status.ErrorOccurred); Assert.False(status.Pending); Assert.Equal(host, status.ServerName); } // The validator inside HistorianWcfTagClient now allows IntegratedSecurity=false WHEN // explicit UserName + Password are provided (NTLM/Kerberos with non-current-user creds). // It still rejects the no-credentials-at-all case since there's no way to authenticate // against /Hist-Integrated. [Fact] public async Task GetTagMetadataAsync_NoAuthAndNoCredentials_Throws() { HistorianClient client = new(new HistorianClientOptions { Host = "localhost", IntegratedSecurity = false, UserName = string.Empty, Password = string.Empty, }); await Assert.ThrowsAsync( () => client.GetTagMetadataAsync("anytag", CancellationToken.None)); } [Fact] public async Task GetTagMetadataAsync_ExplicitCredentials_AgainstLocalHistorian() { // Live verification of the explicit-creds tag-metadata path. Gated on // HISTORIAN_USER + HISTORIAN_PASSWORD being set; skips cleanly otherwise. The path // routes through WCF Windows transport security with Credentials.Windows.ClientCredential. string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST"); string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG"); string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER"); string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD"); if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag) || string.IsNullOrWhiteSpace(user) || string.IsNullOrWhiteSpace(password) || !OperatingSystem.IsWindows()) { return; } HistorianClient client = new(new HistorianClientOptions { Host = host, IntegratedSecurity = false, UserName = user, Password = password, }); AVEVA.Historian.Client.Models.HistorianTagMetadata? metadata = await client.GetTagMetadataAsync(testTag, CancellationToken.None); Assert.NotNull(metadata); Assert.Equal(testTag, metadata.Name); } [Fact] public async Task GetTagMetadataAsync_ReturnsConfiguredTestTagMetadata() { string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST"); string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG"); if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag)) { return; } int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_PORT"), out int parsedPort) ? parsedPort : HistorianClientOptions.DefaultPort; HistorianClient client = new(new HistorianClientOptions { Host = host, Port = port, IntegratedSecurity = true, UserName = Environment.GetEnvironmentVariable("HISTORIAN_USER") ?? string.Empty, Password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD") ?? string.Empty }); AVEVA.Historian.Client.Models.HistorianTagMetadata? metadata = await client.GetTagMetadataAsync(testTag, CancellationToken.None); Assert.NotNull(metadata); Assert.Equal(testTag, metadata.Name); Assert.NotNull(metadata.Key); } [Fact] public async Task EnsureTagAsync_AndDeleteTagAsync_RoundTrip_AgainstLocalHistorian() { // Per docs/plans/write-commands-reverse-engineering.md safety rules: localhost only, // sandbox tag name must start with "RetestSdkWrite", tag is created if missing and // always deleted at the end so the test leaves zero residue. string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST"); string? sandboxTag = Environment.GetEnvironmentVariable("HISTORIAN_WRITE_SANDBOX_TAG"); if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows()) { return; } if (string.IsNullOrWhiteSpace(sandboxTag) || !sandboxTag.StartsWith("RetestSdkWrite", StringComparison.Ordinal)) { return; // safety gate per the plan } HistorianClient client = new(new HistorianClientOptions { Host = host, IntegratedSecurity = true, Transport = HistorianTransport.LocalPipe }); AVEVA.Historian.Client.Models.HistorianTagDefinition definition = new() { TagName = sandboxTag, Description = "SDK live integration test sandbox", EngineeringUnit = "test", DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float, MinEU = 0.0, MaxEU = 100.0, }; // Both EnsureTagAsync and DeleteTagAsync now work end-to-end against the live // Historian. Open2 must use write-enabled connectionMode 0x401 (not the default // 0x402 read-only); the EnsT2 InBuff layout is corrected to native parity (144 // bytes incl 0x4E leading marker, no trailing 01 01 01 closing markers). bool ensured = await client.EnsureTagAsync(definition, CancellationToken.None); Assert.True(ensured, "EnsureTagAsync returned false against the live Historian."); bool deleted = await client.DeleteTagAsync(sandboxTag, CancellationToken.None); Assert.True(deleted, "DeleteTagAsync returned false against the live Historian."); } // Round-trip every live-verified analog data type + the non-default-range case. The // sandbox tag name is suffixed per case so the runs don't collide. Always cleans up. [Theory] [InlineData("RetestSdkWriteFloatRT", AVEVA.Historian.Client.Models.HistorianDataType.Float, 0.0, 100.0, 0.0, 100.0)] [InlineData("RetestSdkWriteDoubleRT", AVEVA.Historian.Client.Models.HistorianDataType.Double, 0.0, 100.0, 0.0, 100.0)] [InlineData("RetestSdkWriteInt2RT", AVEVA.Historian.Client.Models.HistorianDataType.Int2, 0.0, 100.0, 0.0, 100.0)] [InlineData("RetestSdkWriteInt4RT", AVEVA.Historian.Client.Models.HistorianDataType.Int4, 0.0, 100.0, 0.0, 100.0)] [InlineData("RetestSdkWriteUInt4RT", AVEVA.Historian.Client.Models.HistorianDataType.UInt4, 0.0, 100.0, 0.0, 100.0)] [InlineData("RetestSdkWriteFloatRangesRT", AVEVA.Historian.Client.Models.HistorianDataType.Float, -50.0, 200.0, 10.0, 4095.0)] public async Task EnsureTagAsync_AndDeleteTagAsync_RoundTrip_PerDataTypeAndRange( string sandboxTag, AVEVA.Historian.Client.Models.HistorianDataType dataType, double minEU, double maxEU, double minRaw, double maxRaw) { string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST"); if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows()) { return; } HistorianClient client = new(new HistorianClientOptions { Host = host, IntegratedSecurity = true, Transport = HistorianTransport.LocalPipe }); AVEVA.Historian.Client.Models.HistorianTagDefinition definition = new() { TagName = sandboxTag, Description = $"SDK round-trip {dataType}", EngineeringUnit = "test", DataType = dataType, MinEU = minEU, MaxEU = maxEU, MinRaw = minRaw, MaxRaw = maxRaw, }; try { bool ensured = await client.EnsureTagAsync(definition, CancellationToken.None); Assert.True(ensured, $"EnsureTagAsync({dataType}) returned false against the live Historian."); } finally { // Always clean up — DeleteTagAsync returns true on a freshly-created tag. await client.DeleteTagAsync(sandboxTag, CancellationToken.None); } } [Fact] public async Task GetTagMetadataAsync_PopulatesDescriptionAndEuRangeForAnalogTag() { string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST"); if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows()) { return; } // SysTimeSec is a built-in analog UInt16 tag with non-empty Description, MaxEU, // and an EngineeringUnit. Verifies the parser populates those new fields end-to-end. const string analogTag = "SysTimeSec"; HistorianClient client = new(new HistorianClientOptions { Host = host, IntegratedSecurity = true, Transport = HistorianTransport.LocalPipe }); AVEVA.Historian.Client.Models.HistorianTagMetadata? metadata = await client.GetTagMetadataAsync(analogTag, CancellationToken.None); Assert.NotNull(metadata); Assert.Equal(analogTag, metadata.Name); Assert.False(string.IsNullOrWhiteSpace(metadata.Description)); Assert.NotNull(metadata.MaxRaw); Assert.True(metadata.MaxRaw is > 0 and <= 1e15); Assert.False(string.IsNullOrWhiteSpace(metadata.EngineeringUnit)); } }