Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ConfigSourceOptionsValidatorTests.cs
T
Joseph Doherty 9d1e60c00c feat(mesh): ConfigSource/ConfigServe options + validator (Phase 3 dark switch)
ConfigSource:Mode = Direct (default, read central SQL) | FetchAndCache (fetch
the artifact from central over gRPC, read LocalDb). The validator fails host
start on a FetchAndCache shape that cannot fetch — no endpoints, a non-http(s)
endpoint, no shared key, or a non-positive timeout — because each otherwise
surfaces as a silent absence (a deploy that never applies). ConfigServe (the
central serve surface) takes a plain Configure: a 0 port is a valid "disabled".

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-22 19:03:39 -04:00

121 lines
3.6 KiB
C#

using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
/// <summary>
/// A <c>FetchAndCache</c> node with no endpoint or no key does not error — it simply never
/// applies a deployment, and the deploy times out naming a node that is otherwise healthy. These
/// tests make that misconfiguration a loud host-start failure instead.
/// </summary>
public class ConfigSourceOptionsValidatorTests
{
private static ValidateOptionsResult Validate(ConfigSourceOptions o) =>
new ConfigSourceOptionsValidator().Validate(ConfigSourceOptions.SectionName, o);
private static ConfigSourceOptions ValidFetch() => new()
{
Mode = ConfigSourceOptions.ModeFetchAndCache,
CentralFetchEndpoints = ["http://central-1:4055", "http://central-2:4055"],
ApiKey = "shared-key",
FetchTimeoutSeconds = 30,
};
[Fact]
public void Default_options_are_valid()
{
// The default is Direct with everything empty — the pre-Phase-3 behaviour. A node that never
// sets this section must validate, which is the premise of the dark switch.
Validate(new ConfigSourceOptions()).Succeeded.ShouldBeTrue();
}
[Fact]
public void Unknown_mode_fails()
{
var result = Validate(new ConfigSourceOptions { Mode = "cache" });
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain("cache");
}
[Fact]
public void Mode_matching_is_case_insensitive()
{
Validate(new ConfigSourceOptions
{
Mode = "fetchandcache",
CentralFetchEndpoints = ["http://central-1:4055"],
ApiKey = "k",
}).Succeeded.ShouldBeTrue();
}
[Fact]
public void FetchAndCache_with_no_endpoints_fails()
{
var o = ValidFetch();
o.CentralFetchEndpoints = [];
var result = Validate(o);
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain(nameof(ConfigSourceOptions.CentralFetchEndpoints));
}
[Fact]
public void FetchAndCache_with_a_non_http_endpoint_fails()
{
var o = ValidFetch();
o.CentralFetchEndpoints = ["central-1:4055"];
var result = Validate(o);
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain("central-1:4055");
}
[Fact]
public void FetchAndCache_with_an_empty_key_fails()
{
var o = ValidFetch();
o.ApiKey = "";
var result = Validate(o);
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain(nameof(ConfigSourceOptions.ApiKey));
}
[Fact]
public void FetchAndCache_with_a_non_positive_timeout_fails()
{
var o = ValidFetch();
o.FetchTimeoutSeconds = 0;
var result = Validate(o);
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain(nameof(ConfigSourceOptions.FetchTimeoutSeconds));
}
[Fact]
public void A_fully_populated_FetchAndCache_config_is_valid()
{
Validate(ValidFetch()).Succeeded.ShouldBeTrue();
}
[Fact]
public void Direct_ignores_the_empty_fetch_surface()
{
// Under Direct the endpoints/key are never read, so their emptiness must not fail validation —
// otherwise every admin-only and every Direct node would be forced to carry fetch config.
Validate(new ConfigSourceOptions
{
Mode = ConfigSourceOptions.ModeDirect,
CentralFetchEndpoints = [],
ApiKey = "",
FetchTimeoutSeconds = 0,
}).Succeeded.ShouldBeTrue();
}
}