13d628a07f
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
464 lines
22 KiB
C#
464 lines
22 KiB
C#
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Options;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// archreview 06/S-11 — verifies the net-new <see cref="ServerHistorianOptionsValidator"/> (built on
|
|
/// the shared <c>ZB.MOM.WW.Configuration</c> <c>OptionsValidatorBase</c>/<c>ValidationBuilder</c>).
|
|
/// The fail tier is deliberately narrow: only <b>provably-crashing</b> configs fail — an enabled
|
|
/// historian (or an enabled <c>AlarmHistorian</c>, which sources its gateway connection from the
|
|
/// <c>ServerHistorian</c> section) with an empty / non-absolute / non-http(s) <c>Endpoint</c>, i.e.
|
|
/// exactly the configs that would otherwise throw <see cref="System.UriFormatException"/> deep in the
|
|
/// gateway client factory at startup. Empty <c>ApiKey</c> / non-positive <c>MaxTieClusterOverfetch</c>
|
|
/// stay operator warnings in <see cref="ServerHistorianOptions.Validate"/> (they degrade, not crash).
|
|
/// </summary>
|
|
public sealed class ServerHistorianOptionsValidatorTests
|
|
{
|
|
private static ServerHistorianOptionsValidator Sut(bool alarmHistorianEnabled = false)
|
|
{
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["AlarmHistorian:Enabled"] = alarmHistorianEnabled ? "true" : "false",
|
|
})
|
|
.Build();
|
|
return new ServerHistorianOptionsValidator(configuration);
|
|
}
|
|
|
|
/// <summary>Enabled historian with an empty endpoint fails (would crash-loop in the factory).</summary>
|
|
/// <summary>An unknown backend always fails, even when nothing consumes the section.</summary>
|
|
[Fact]
|
|
public void Unknown_backend_fails_even_when_not_consumed()
|
|
{
|
|
var result = Sut().Validate(null, new ServerHistorianOptions { Backend = "Nope" });
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:Backend") && f.Contains("Nope") && f.Contains("Gateway"));
|
|
}
|
|
|
|
/// <summary>Backend names are case-insensitive; the gateway checks still apply (control: no ApiKey fails).</summary>
|
|
[Fact]
|
|
public void Lowercase_gateway_backend_still_enforces_gateway_checks()
|
|
{
|
|
var result = Sut().Validate(null, new ServerHistorianOptions
|
|
{
|
|
Backend = "gateway", Enabled = true, Endpoint = "https://host:5222", ApiKey = "",
|
|
});
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:ApiKey"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The gateway-only checks (Endpoint, ApiKey, UseTls) do not apply to a non-gateway backend. Needs a
|
|
/// registered non-gateway backend, which does not exist yet, so this asserts the gate through
|
|
/// <see cref="ServerHistorianOptions.IsGatewayBackend"/> and the registry's unknown-name failure only.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Gateway_checks_are_gated_on_backend_being_gateway()
|
|
{
|
|
new ServerHistorianOptions { Backend = "SqlServer" }.IsGatewayBackend.ShouldBeFalse();
|
|
new ServerHistorianOptions { Backend = "gateway" }.IsGatewayBackend.ShouldBeTrue();
|
|
new ServerHistorianOptions { Backend = "SqlServer", Enabled = true }.Validate().ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Enabled_with_empty_endpoint_fails()
|
|
{
|
|
var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "" });
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint"));
|
|
}
|
|
|
|
/// <summary>Enabled with a relative (non-absolute) URI fails.</summary>
|
|
[Fact]
|
|
public void Enabled_with_relative_uri_fails()
|
|
{
|
|
var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "host:5222" });
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint"));
|
|
}
|
|
|
|
/// <summary>Enabled with a non-http(s) scheme (ftp) fails.</summary>
|
|
[Fact]
|
|
public void Enabled_with_bad_scheme_fails()
|
|
{
|
|
var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "ftp://host:5222" });
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enabled with a valid absolute https endpoint <b>and a key</b> succeeds. The key is not
|
|
/// incidental: this case previously omitted it and still asserted success, which pinned a config
|
|
/// that actually crash-loops the host — see <see cref="Consumed_with_empty_api_key_fails"/>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Enabled_with_valid_https_endpoint_succeeds()
|
|
{
|
|
var result = Sut().Validate(null, new ServerHistorianOptions
|
|
{
|
|
Enabled = true, Endpoint = "https://host:5222", ApiKey = "histgw_id_secret",
|
|
});
|
|
|
|
result.Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>A disabled section may legitimately be empty — no failure.</summary>
|
|
[Fact]
|
|
public void Disabled_with_empty_endpoint_succeeds()
|
|
{
|
|
var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = false, Endpoint = "" });
|
|
|
|
result.Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The residual corner: <c>ServerHistorian:Enabled=false</c> but <c>AlarmHistorian:Enabled=true</c>
|
|
/// (which sources its connection from the ServerHistorian section) with an empty endpoint fails —
|
|
/// and the message names BOTH sections so the operator understands why a disabled section is required.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Disabled_but_alarm_historian_enabled_with_empty_endpoint_fails_naming_both_sections()
|
|
{
|
|
var result = Sut(alarmHistorianEnabled: true)
|
|
.Validate(null, new ServerHistorianOptions { Enabled = false, Endpoint = "" });
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldContain(f =>
|
|
f.Contains("ServerHistorian:Endpoint") && f.Contains("AlarmHistorian:Enabled"));
|
|
}
|
|
|
|
/// <summary>Alarm-only mode with a valid endpoint and a key succeeds.</summary>
|
|
[Fact]
|
|
public void Disabled_but_alarm_historian_enabled_with_valid_endpoint_succeeds()
|
|
{
|
|
var result = Sut(alarmHistorianEnabled: true)
|
|
.Validate(null, new ServerHistorianOptions
|
|
{
|
|
Enabled = false, Endpoint = "https://host:5222", ApiKey = "histgw_id_secret",
|
|
});
|
|
|
|
result.Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>
|
|
/// An empty <c>ApiKey</c> on a consumed section fails, because it <b>crashes</b> — it does not
|
|
/// degrade.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Found by the LocalDb Phase 2 live gate. The rig booted with
|
|
/// <c>AlarmHistorian:Enabled=true</c> and a valid endpoint but no key, and every driver
|
|
/// node crash-looped on
|
|
/// <c>ArgumentException: The gateway API key must not be empty (Parameter 'ApiKey')</c>
|
|
/// thrown by <c>HistorianGatewayClientOptions.Validate()</c> — reached through
|
|
/// <c>GatewayHistorian.CreateAlarmWriter</c> during Akka startup.
|
|
/// </para>
|
|
/// <para>
|
|
/// That is the *same* factory path, and the same crash-loop-under-a-restart-policy
|
|
/// failure mode, this validator was built to convert into a named
|
|
/// <c>OptionsValidationException</c>. The previous "empty ApiKey degrades, the gateway
|
|
/// just rejects calls" premise was simply wrong: the client validates its own options
|
|
/// at construction, so the process never reaches the point of making a call.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public void Consumed_with_empty_api_key_fails()
|
|
{
|
|
var result = Sut(alarmHistorianEnabled: true)
|
|
.Validate(null, new ServerHistorianOptions
|
|
{
|
|
Enabled = false, Endpoint = "https://host:5222", ApiKey = "",
|
|
});
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldContain(f =>
|
|
f.Contains("ServerHistorian:ApiKey") && f.Contains("AlarmHistorian:Enabled"));
|
|
}
|
|
|
|
/// <summary>The read path has the identical crash, so it is gated identically.</summary>
|
|
[Fact]
|
|
public void Enabled_with_empty_api_key_fails()
|
|
{
|
|
var result = Sut().Validate(null, new ServerHistorianOptions
|
|
{
|
|
Enabled = true, Endpoint = "https://host:5222", ApiKey = "",
|
|
});
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:ApiKey"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The key must never be echoed. An endpoint is not a secret and is quoted to make the error
|
|
/// actionable; a key is, so the failure names only the setting.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Api_key_failure_never_echoes_the_key()
|
|
{
|
|
var result = Sut().Validate(null, new ServerHistorianOptions
|
|
{
|
|
Enabled = true, Endpoint = "https://host:5222", ApiKey = " ",
|
|
});
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldNotContain(f => f.Contains(" ", StringComparison.Ordinal));
|
|
}
|
|
|
|
/// <summary>A section nobody consumes may be keyless — no failure.</summary>
|
|
[Fact]
|
|
public void Disabled_with_empty_api_key_succeeds()
|
|
{
|
|
var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = false, ApiKey = "" });
|
|
|
|
result.Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>UseTls=true</c> against an <c>http://</c> endpoint fails — the third member of the
|
|
/// crash-at-construction family, and the easiest of the three for an operator to trip.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Also found by the Phase 2 live gate, immediately after the <c>ApiKey</c> fix: the rig points
|
|
/// at a deliberately unresolvable <c>http://</c> endpoint, and <c>UseTls</c> defaults to
|
|
/// <see langword="true"/>, so every node crash-looped a second time on
|
|
/// <c>ArgumentException: UseTls requires an https gateway endpoint</c>. Switching an endpoint
|
|
/// from https to http without clearing <c>UseTls</c> is an ordinary migration slip, and it takes
|
|
/// the host down rather than degrading it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void Consumed_with_use_tls_against_http_endpoint_fails()
|
|
{
|
|
var result = Sut().Validate(null, new ServerHistorianOptions
|
|
{
|
|
Enabled = true, Endpoint = "http://host:5222", ApiKey = "histgw_id_secret", UseTls = true,
|
|
});
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldContain(f =>
|
|
f.Contains("ServerHistorian:UseTls") && f.Contains("http://host:5222"));
|
|
}
|
|
|
|
/// <summary>An http endpoint with TLS explicitly off is the valid h2c shape — no failure.</summary>
|
|
[Fact]
|
|
public void Consumed_with_http_endpoint_and_tls_off_succeeds()
|
|
{
|
|
var result = Sut().Validate(null, new ServerHistorianOptions
|
|
{
|
|
Enabled = true, Endpoint = "http://host:5222", ApiKey = "histgw_id_secret", UseTls = false,
|
|
});
|
|
|
|
result.Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The mirror slip — <c>UseTls=false</c> against an <c>https://</c> endpoint — also fails, so the
|
|
/// scheme and the flag are pinned to agree in both directions rather than in one.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Consumed_with_tls_off_against_https_endpoint_fails()
|
|
{
|
|
var result = Sut().Validate(null, new ServerHistorianOptions
|
|
{
|
|
Enabled = true, Endpoint = "https://host:5222", ApiKey = "histgw_id_secret", UseTls = false,
|
|
});
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:UseTls"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wiring guard (the "register-AND-consume" trap): resolving <c>IOptions<ServerHistorianOptions>.Value</c>
|
|
/// after <see cref="ServiceCollectionExtensions.AddValidatedOptions{TOptions,TValidator}"/> throws
|
|
/// <see cref="OptionsValidationException"/> for an enabled+empty section — proving the validator is
|
|
/// attached to the options pipeline (not merely defined). <c>ValidateOnStart</c> reaching host start
|
|
/// is already proven by the LDAP precedent.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AddValidatedOptions_wiring_throws_on_enabled_empty_endpoint()
|
|
{
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["ServerHistorian:Enabled"] = "true",
|
|
["ServerHistorian:Endpoint"] = "",
|
|
})
|
|
.Build();
|
|
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton<IConfiguration>(configuration);
|
|
services.AddValidatedOptions<ServerHistorianOptions, ServerHistorianOptionsValidator>(
|
|
configuration, ServerHistorianOptions.SectionName);
|
|
using var provider = services.BuildServiceProvider();
|
|
|
|
var ex = Should.Throw<OptionsValidationException>(
|
|
() => _ = provider.GetRequiredService<IOptions<ServerHistorianOptions>>().Value);
|
|
ex.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint"));
|
|
}
|
|
|
|
private static ServerHistorianOptionsValidator SqlSut(string? connectionString)
|
|
{
|
|
var values = new Dictionary<string, string?> { ["AlarmHistorian:Enabled"] = "false" };
|
|
if (connectionString is not null) values["ServerHistorian:SqlServer:ConnectionString"] = connectionString;
|
|
return new ServerHistorianOptionsValidator(new ConfigurationBuilder().AddInMemoryCollection(values).Build());
|
|
}
|
|
|
|
/// <summary>SqlServer with a connection string passes; the gateway Endpoint/ApiKey/UseTls checks do not fire.</summary>
|
|
[Fact]
|
|
public void SqlServer_backend_with_connection_string_passes_without_gateway_settings()
|
|
{
|
|
var result = SqlSut("Server=localhost;Database=h").Validate(null,
|
|
new ServerHistorianOptions { Backend = "SqlServer", Enabled = true });
|
|
|
|
result.Failed.ShouldBeFalse(string.Join("; ", result.Failures ?? []));
|
|
}
|
|
|
|
/// <summary>SqlServer with no connection string fails, naming the key.</summary>
|
|
[Fact]
|
|
public void SqlServer_backend_without_connection_string_fails_naming_the_key()
|
|
{
|
|
var result = SqlSut(null).Validate(null, new ServerHistorianOptions { Backend = "SqlServer", Enabled = true });
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:SqlServer:ConnectionString"));
|
|
result.Failures.ShouldNotContain(f => f.Contains("ServerHistorian:Endpoint"));
|
|
}
|
|
|
|
/// <summary>Unconsumed SqlServer section (nothing enabled) is not validated.</summary>
|
|
[Fact]
|
|
public void SqlServer_backend_not_consumed_passes_without_connection_string() =>
|
|
SqlSut(null).Validate(null, new ServerHistorianOptions { Backend = "SqlServer" }).Failed.ShouldBeFalse();
|
|
|
|
private static IConfiguration BadSchemaConfig(bool alarmHistorianEnabled) =>
|
|
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["AlarmHistorian:Enabled"] = alarmHistorianEnabled ? "true" : "false",
|
|
["ServerHistorian:SqlServer:ConnectionString"] = "Server=localhost;Database=h",
|
|
["ServerHistorian:SqlServer:Schema"] = "bad;schema",
|
|
}).Build();
|
|
|
|
/// <summary>
|
|
/// An invalid <c>ServerHistorian:SqlServer:Schema</c> must not fail the host when nothing consumes the section:
|
|
/// <see cref="HistorianBackendRegistry.Resolve"/> (which Program.cs calls unconditionally) does not throw and
|
|
/// the validator does not check it — the same consumer gate the connection string has.
|
|
/// </summary>
|
|
[Fact]
|
|
public void SqlServer_invalid_schema_not_consumed_neither_resolve_nor_validation_fails()
|
|
{
|
|
var configuration = BadSchemaConfig(alarmHistorianEnabled: false);
|
|
var options = new ServerHistorianOptions { Backend = "SqlServer" };
|
|
|
|
Should.NotThrow(() => HistorianBackendRegistry.Resolve(options, configuration));
|
|
new ServerHistorianOptionsValidator(configuration).Validate(null, options).Failed.ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>Consumed (here by AlarmHistorian alone), the same invalid schema is a named validator error.</summary>
|
|
[Fact]
|
|
public void SqlServer_invalid_schema_consumed_is_a_validator_error_naming_the_key()
|
|
{
|
|
var configuration = BadSchemaConfig(alarmHistorianEnabled: true);
|
|
var options = new ServerHistorianOptions { Backend = "SqlServer" };
|
|
|
|
Should.NotThrow(() => HistorianBackendRegistry.Resolve(options, configuration));
|
|
var result = new ServerHistorianOptionsValidator(configuration).Validate(null, options);
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:SqlServer:Schema"));
|
|
}
|
|
|
|
private static ServerHistorianOptionsValidator QuestDbSut(string? connectionString, string? prefix = null, bool alarmHistorianEnabled = false)
|
|
{
|
|
var values = new Dictionary<string, string?> { ["AlarmHistorian:Enabled"] = alarmHistorianEnabled ? "true" : "false" };
|
|
if (connectionString is not null) values["ServerHistorian:QuestDb:ConnectionString"] = connectionString;
|
|
if (prefix is not null) values["ServerHistorian:QuestDb:TablePrefix"] = prefix;
|
|
return new ServerHistorianOptionsValidator(new ConfigurationBuilder().AddInMemoryCollection(values).Build());
|
|
}
|
|
|
|
/// <summary>QuestDb with a connection string passes; the gateway Endpoint/ApiKey/UseTls checks do not fire.</summary>
|
|
[Fact]
|
|
public void QuestDb_backend_with_connection_string_passes_without_gateway_settings()
|
|
{
|
|
var result = QuestDbSut("Host=questdb;Port=8812").Validate(null,
|
|
new ServerHistorianOptions { Backend = "QuestDb", Enabled = true });
|
|
|
|
result.Failed.ShouldBeFalse(string.Join("; ", result.Failures ?? []));
|
|
}
|
|
|
|
/// <summary>QuestDb with no connection string fails, naming the key, and never asks for gateway settings.</summary>
|
|
[Fact]
|
|
public void QuestDb_backend_without_connection_string_fails_naming_the_key()
|
|
{
|
|
var result = QuestDbSut(null).Validate(null, new ServerHistorianOptions { Backend = "questdb", Enabled = true });
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:QuestDb:ConnectionString"));
|
|
result.Failures.ShouldNotContain(f => f.Contains("ServerHistorian:Endpoint"));
|
|
result.Failures.ShouldNotContain(f => f.Contains("ServerHistorian:ApiKey"));
|
|
}
|
|
|
|
/// <summary>Every <c>QuestDbHistorianOptions.Validate()</c> error is reported, each naming its key.</summary>
|
|
[Fact]
|
|
public void QuestDb_backend_reports_every_options_error_naming_the_key()
|
|
{
|
|
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["AlarmHistorian:Enabled"] = "false",
|
|
["ServerHistorian:QuestDb:TablePrefix"] = "bad;prefix",
|
|
["ServerHistorian:QuestDb:RetentionDays"] = "0",
|
|
["ServerHistorian:QuestDb:CommandTimeout"] = "00:00:00",
|
|
["ServerHistorian:QuestDb:VisibilityTimeout"] = "-00:00:01",
|
|
}).Build();
|
|
var result = new ServerHistorianOptionsValidator(configuration)
|
|
.Validate(null, new ServerHistorianOptions { Backend = "QuestDb", Enabled = true });
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
foreach (var key in new[] { "ConnectionString", "TablePrefix", "RetentionDays", "CommandTimeout", "VisibilityTimeout" })
|
|
result.Failures.ShouldContain(f => f.Contains($"ServerHistorian:QuestDb:{key}"), key);
|
|
}
|
|
|
|
/// <summary>Unconsumed QuestDb section (nothing enabled) is not validated.</summary>
|
|
[Fact]
|
|
public void QuestDb_backend_not_consumed_passes_without_connection_string() =>
|
|
QuestDbSut(null).Validate(null, new ServerHistorianOptions { Backend = "QuestDb" }).Failed.ShouldBeFalse();
|
|
|
|
/// <summary>
|
|
/// An invalid <c>ServerHistorian:QuestDb:TablePrefix</c> must not fail the host when nothing consumes the
|
|
/// section: <see cref="HistorianBackendRegistry.Resolve"/> does not throw and the validator does not check it.
|
|
/// </summary>
|
|
[Fact]
|
|
public void QuestDb_invalid_prefix_not_consumed_neither_resolve_nor_validation_fails()
|
|
{
|
|
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["AlarmHistorian:Enabled"] = "false",
|
|
["ServerHistorian:QuestDb:ConnectionString"] = "Host=questdb;Port=8812",
|
|
["ServerHistorian:QuestDb:TablePrefix"] = "bad;prefix",
|
|
}).Build();
|
|
var options = new ServerHistorianOptions { Backend = "QuestDb" };
|
|
|
|
Should.NotThrow(() => HistorianBackendRegistry.Resolve(options, configuration));
|
|
new ServerHistorianOptionsValidator(configuration).Validate(null, options).Failed.ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>Consumed (here by AlarmHistorian alone), the same invalid prefix is a named validator error.</summary>
|
|
[Fact]
|
|
public void QuestDb_invalid_prefix_consumed_is_a_validator_error_naming_the_key()
|
|
{
|
|
var options = new ServerHistorianOptions { Backend = "QuestDb" };
|
|
var result = QuestDbSut("Host=questdb;Port=8812", "bad;prefix", alarmHistorianEnabled: true).Validate(null, options);
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:QuestDb:TablePrefix"));
|
|
}
|
|
}
|