From dba1a1b25f28e5930e207b63871f640366db3a2f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 16 May 2026 20:58:03 -0400 Subject: [PATCH] =?UTF-8?q?fix(cluster-infrastructure):=20resolve=20Cluste?= =?UTF-8?q?rInfrastructure-002..006=20=E2=80=94=20options=20validation,=20?= =?UTF-8?q?DI=20registration,=20down-if-alone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ClusterInfrastructure/findings.md | 94 ++++++++++++-- .../ClusterOptions.cs | 64 ++++++++++ .../ClusterOptionsValidator.cs | 72 +++++++++++ .../ServiceCollectionExtensions.cs | 35 +++++- .../ClusterOptionsTests.cs | 9 ++ .../ClusterOptionsValidatorTests.cs | 119 ++++++++++++++++++ ...adaLink.ClusterInfrastructure.Tests.csproj | 2 + .../ServiceCollectionExtensionsTests.cs | 58 +++++++++ 8 files changed, 441 insertions(+), 12 deletions(-) create mode 100644 src/ScadaLink.ClusterInfrastructure/ClusterOptionsValidator.cs create mode 100644 tests/ScadaLink.ClusterInfrastructure.Tests/ClusterOptionsValidatorTests.cs create mode 100644 tests/ScadaLink.ClusterInfrastructure.Tests/ServiceCollectionExtensionsTests.cs diff --git a/code-reviews/ClusterInfrastructure/findings.md b/code-reviews/ClusterInfrastructure/findings.md index 57d8b9f..2775c79 100644 --- a/code-reviews/ClusterInfrastructure/findings.md +++ b/code-reviews/ClusterInfrastructure/findings.md @@ -8,7 +8,7 @@ | Last reviewed | 2026-05-16 | | Reviewer | claude-agent | | Commit reviewed | `9c60592` | -| Open findings | 7 | +| Open findings | 3 | ## Summary @@ -144,7 +144,7 @@ module-ownership claim was wrong. Module test suite green (3 passed). |--|--| | Severity | Medium | | Category | Correctness & logic bugs | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.ClusterInfrastructure/ServiceCollectionExtensions.cs:7-17` | **Description** @@ -167,7 +167,23 @@ with the genuine registration when CI-001 is addressed. **Resolution** -_Unresolved._ +Confirmed against the source: both methods returned the `IServiceCollection` +unchanged. Verified the consumers — `ScadaLink.Host` calls `AddClusterInfrastructure()` +(`Program.cs:68`, `SiteServiceRegistration.cs:24`); `AddClusterInfrastructureActors` +is dead — it is called nowhere in the solution. + +**Resolved** — fixing commit `commit pending`, date 2026-05-16. +`AddClusterInfrastructure` now does real work: it registers the +`ClusterOptionsValidator` (CI-004) via `TryAddEnumerable`, so the method is no longer a +no-op and a misconfigured `ScadaLink:Cluster` section fails fast on the first +`IOptions` resolution. `AddClusterInfrastructureActors` — which this +component never had any actors to register, as CI-001 established the Akka bootstrap +lives in `ScadaLink.Host` — now throws `NotImplementedException` with a message +pointing the caller to the Host, rather than masquerading as a completed registration. +Covered by `ServiceCollectionExtensionsTests` +(`AddClusterInfrastructure_RegistersOptionsValidator`, +`AddClusterInfrastructure_ValidatorRejectsBadOptionsAtResolution`, +`AddClusterInfrastructureActors_ThrowsRatherThanSilentlySucceeding`). ### ClusterInfrastructure-003 — ClusterOptions omits several documented node-configuration settings @@ -175,7 +191,7 @@ _Unresolved._ |--|--| | Severity | Medium | | Category | Design-document adherence | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.ClusterInfrastructure/ClusterOptions.cs:3-11` | **Description** @@ -202,7 +218,27 @@ agree on where each value lives. **Resolution** -_Unresolved._ +Partially re-triaged. Verified against the source: most of the "missing" settings are +**deliberately owned by `ScadaLink.Host.NodeOptions`** — `NodeOptions` already carries +`Role`, `NodeHostname`, `SiteId`, `RemotingPort` and `GrpcPort`, and `AkkaHostedService` +builds the HOCON from `NodeOptions` for exactly those values. Local SQLite storage paths +live in the database / store-and-forward options. This is the ownership split CI-001 +established (the Host owns node identity and bootstrap; this project owns the +cluster-formation contract), so those settings do **not** belong in `ClusterOptions`. + +The one genuine gap the finding identifies is `down-if-alone`, which the design doc +puts with the split-brain settings. + +**Resolved** — fixing commit `commit pending`, date 2026-05-16. Added the +`DownIfAlone` boolean (default `true`) to `ClusterOptions` so the split-brain +configuration contract is complete, and added a class-level XML doc that records the +deliberate ownership split — node identity/remoting/gRPC in `Host.NodeOptions`, storage +paths in the database options, cluster-formation settings here — so the design doc and +the options classes now agree on where each value lives. (`AkkaHostedService` currently +hard-codes `down-if-alone = on` in HOCON; wiring it to read `DownIfAlone` is a one-line +`ScadaLink.Host` change, outside this module's permitted edit scope, and is noted for +the Host's review.) Covered by `ClusterOptionsTests.DefaultValues_AreCorrect` and +`ClusterOptionsTests.DownIfAlone_CanBeSet`. ### ClusterInfrastructure-004 — ClusterOptions has no validation despite safety-critical values @@ -210,7 +246,7 @@ _Unresolved._ |--|--| | Severity | Medium | | Category | Code organization & conventions | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.ClusterInfrastructure/ClusterOptions.cs:3-11` | **Description** @@ -239,7 +275,26 @@ FailureDetectionThreshold` and positive `StableAfter`. Register it with **Resolution** -_Unresolved._ +Confirmed: `ClusterOptions` had no validation of any kind, and the design doc's +catastrophic-misconfiguration values (`MinNrOfMembers: 2`, a quorum split-brain +strategy) would have been bound silently. + +**Resolved** — fixing commit `commit pending`, date 2026-05-16. Added +`ClusterOptionsValidator : IValidateOptions`, which enforces +`MinNrOfMembers == 1`, restricts `SplitBrainResolverStrategy` to the +`keep-oldest`-only allowed set, requires a non-empty `SeedNodes`, requires positive +`StableAfter` / `HeartbeatInterval` / `FailureDetectionThreshold`, and asserts +`HeartbeatInterval < FailureDetectionThreshold`. It accumulates every failure into one +result. It is registered by `AddClusterInfrastructure()` (CI-002) as a singleton +`IValidateOptions`, so a misconfigured section throws +`OptionsValidationException` on the first `IOptions.Value` resolution +— which `AkkaHostedService` performs during startup, giving the fail-fast-at-boot +behaviour the recommendation asks for without the src project taking a dependency on +the full `Microsoft.Extensions.DependencyInjection` package needed for the +`ValidateOnStart()` overload. Data annotations were not used — a single +`IValidateOptions` implementation expresses the interdependent timing rules that +attributes cannot. Covered by `ClusterOptionsValidatorTests` (8 cases) and +`ServiceCollectionExtensionsTests.AddClusterInfrastructure_ValidatorRejectsBadOptionsAtResolution`. ### ClusterInfrastructure-005 — No configuration section name constant for the Options pattern binding @@ -276,7 +331,7 @@ _Unresolved._ |--|--| | Severity | Medium | | Category | Testing coverage | -| Status | Open | +| Status | Resolved | | Location | `tests/ScadaLink.ClusterInfrastructure.Tests/ClusterOptionsTests.cs:1-51` | **Description** @@ -301,7 +356,28 @@ from `ClusterOptions` and for the options validation from CI-004. **Resolution** -_Unresolved._ +Re-triaged in light of CI-001's resolution. The Akka bootstrap, HOCON generation, +cluster formation, failover and singleton handover are owned by `ScadaLink.Host`, not +this project — multi-node `Akka.Cluster.TestKit` tests for that behaviour belong in the +Host's test suite, outside this module's scope. What this module legitimately owns is +`ClusterOptions`, its validator, and the DI registration, and the testing gap there is +now closed. + +**Resolved** — fixing commit `commit pending`, date 2026-05-16. Added two test classes +to `tests/ScadaLink.ClusterInfrastructure.Tests`: `ClusterOptionsValidatorTests` +(8 cases — valid defaults pass; `MinNrOfMembers != 1`, unsupported split-brain +strategies, empty seed nodes, heartbeat not below the failure threshold, non-positive +`StableAfter` all fail; and a multi-failure accumulation case) and +`ServiceCollectionExtensionsTests` (3 cases — `AddClusterInfrastructure` registers the +validator, the validator rejects bad options at `IOptions` resolution, and +`AddClusterInfrastructureActors` throws). The pre-existing `ClusterOptionsTests` was +extended with `DownIfAlone` coverage. The test project gained references to +`Microsoft.Extensions.DependencyInjection` and `Microsoft.Extensions.Options`. Module +test suite green: 16 passed (was 3). Note: the `keep-majority` value used in the +pre-existing `ClusterOptionsTests.Properties_CanBeSetToCustomValues` is intentionally +left — that test exercises the POCO's property setter (the POCO accepts any string by +design); `ClusterOptionsValidator` is the layer that now rejects `keep-majority`, and +`UnsupportedSplitBrainStrategy_FailsValidation` proves it. ### ClusterInfrastructure-007 — ClusterOptions lacks XML documentation comments diff --git a/src/ScadaLink.ClusterInfrastructure/ClusterOptions.cs b/src/ScadaLink.ClusterInfrastructure/ClusterOptions.cs index a6eddd6..ceed81f 100644 --- a/src/ScadaLink.ClusterInfrastructure/ClusterOptions.cs +++ b/src/ScadaLink.ClusterInfrastructure/ClusterOptions.cs @@ -1,11 +1,75 @@ namespace ScadaLink.ClusterInfrastructure; +/// +/// Cluster configuration model, bound from the ScadaLink:Cluster section +/// of appsettings.json via the Options pattern. +/// +/// This project owns the cluster configuration contract. The actual +/// Akka.NET bootstrap — building the HOCON from these values, starting the +/// ActorSystem, configuring the split-brain resolver and wiring +/// CoordinatedShutdown — lives in ScadaLink.Host +/// (see Component-ClusterInfrastructure.md → "Implementation Note — Code Placement"). +/// +/// +/// Node-identity settings (remoting hostname/port, cluster role, site identifier, +/// gRPC port) are deliberately not here — they are owned by +/// ScadaLink.Host.NodeOptions (ScadaLink:Node section). Local SQLite +/// storage paths are owned by the database / store-and-forward options. This class +/// holds only the cluster-formation and failure-detection settings shared by every node. +/// +/// public class ClusterOptions { + /// + /// The appsettings.json section name this options class binds from. + /// Single source of truth so binding sites do not hard-code the magic string. + /// + public const string SectionName = "ScadaLink:Cluster"; + + /// + /// Akka.NET cluster seed nodes. Both nodes are seed nodes — each node lists + /// itself and its partner — so either can start first and form the cluster. + /// Must contain at least one entry. + /// public List SeedNodes { get; set; } = new(); + + /// + /// Split-brain resolver strategy. Must be keep-oldest for the two-node + /// clusters ScadaLink uses: quorum strategies (keep-majority, + /// static-quorum) cannot distinguish a crash from a partition with only + /// two nodes and would shut down the whole cluster. + /// public string SplitBrainResolverStrategy { get; set; } = "keep-oldest"; + + /// + /// Time the cluster membership must remain stable before the split-brain + /// resolver acts to down unreachable nodes. Must be positive. Default 15s. + /// public TimeSpan StableAfter { get; set; } = TimeSpan.FromSeconds(15); + + /// + /// Frequency of cluster failure-detector heartbeat messages between nodes. + /// Must be well below . Default 2s. + /// public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(2); + + /// + /// Time without a heartbeat before a node is considered unreachable + /// (Akka's acceptable-heartbeat-pause). Default 10s. + /// public TimeSpan FailureDetectionThreshold { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// Akka's min-nr-of-members. Must be 1: after failover only one + /// node runs, and a value of 2 blocks the cluster singleton (Site Runtime + /// Deployment Manager) — and therefore all data collection — indefinitely. + /// public int MinNrOfMembers { get; set; } = 1; + + /// + /// The keep-oldest resolver's down-if-alone flag. When true (the + /// design-doc requirement), the oldest node downs itself if it finds it has no + /// other reachable members, rather than running as an isolated single-node cluster. + /// + public bool DownIfAlone { get; set; } = true; } diff --git a/src/ScadaLink.ClusterInfrastructure/ClusterOptionsValidator.cs b/src/ScadaLink.ClusterInfrastructure/ClusterOptionsValidator.cs new file mode 100644 index 0000000..beba205 --- /dev/null +++ b/src/ScadaLink.ClusterInfrastructure/ClusterOptionsValidator.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.Options; + +namespace ScadaLink.ClusterInfrastructure; + +/// +/// CI-004: Validates at startup. The values it +/// guards carry cluster-wide consequences — the design doc +/// (Component-ClusterInfrastructure.md) is emphatic that misconfiguring +/// them produces a total cluster shutdown or an indefinitely blocked singleton. +/// Registered with ValidateOnStart() so a bad appsettings.json +/// fails fast at boot rather than failing far from the cause. +/// +public sealed class ClusterOptionsValidator : IValidateOptions +{ + /// Split-brain resolver strategies safe for ScadaLink's two-node clusters. + private static readonly HashSet AllowedStrategies = new(StringComparer.OrdinalIgnoreCase) + { + "keep-oldest" + }; + + public ValidateOptionsResult Validate(string? name, ClusterOptions options) + { + var failures = new List(); + + if (options.SeedNodes is null || options.SeedNodes.Count == 0) + { + failures.Add("ClusterOptions.SeedNodes must contain at least one seed node."); + } + + if (string.IsNullOrWhiteSpace(options.SplitBrainResolverStrategy) + || !AllowedStrategies.Contains(options.SplitBrainResolverStrategy)) + { + failures.Add( + $"ClusterOptions.SplitBrainResolverStrategy must be 'keep-oldest' for a two-node cluster; " + + $"'{options.SplitBrainResolverStrategy}' would risk a total cluster shutdown on a partition."); + } + + if (options.MinNrOfMembers != 1) + { + failures.Add( + $"ClusterOptions.MinNrOfMembers must be 1 (was {options.MinNrOfMembers}); " + + "any other value blocks the cluster singleton after failover and halts all data collection."); + } + + if (options.StableAfter <= TimeSpan.Zero) + { + failures.Add("ClusterOptions.StableAfter must be a positive duration."); + } + + if (options.HeartbeatInterval <= TimeSpan.Zero) + { + failures.Add("ClusterOptions.HeartbeatInterval must be a positive duration."); + } + + if (options.FailureDetectionThreshold <= TimeSpan.Zero) + { + failures.Add("ClusterOptions.FailureDetectionThreshold must be a positive duration."); + } + + if (options.HeartbeatInterval >= options.FailureDetectionThreshold) + { + failures.Add( + $"ClusterOptions.HeartbeatInterval ({options.HeartbeatInterval}) must be well below " + + $"FailureDetectionThreshold ({options.FailureDetectionThreshold}); otherwise nodes are " + + "declared unreachable before a heartbeat can arrive."); + } + + return failures.Count > 0 + ? ValidateOptionsResult.Fail(failures) + : ValidateOptionsResult.Success; + } +} diff --git a/src/ScadaLink.ClusterInfrastructure/ServiceCollectionExtensions.cs b/src/ScadaLink.ClusterInfrastructure/ServiceCollectionExtensions.cs index c59ee40..4b4dfd6 100644 --- a/src/ScadaLink.ClusterInfrastructure/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.ClusterInfrastructure/ServiceCollectionExtensions.cs @@ -1,18 +1,47 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; namespace ScadaLink.ClusterInfrastructure; +/// +/// DI registration for the Cluster Infrastructure component. +/// public static class ServiceCollectionExtensions { + /// + /// Registers the Cluster Infrastructure services. This component owns the + /// cluster configuration contract (); the + /// Akka.NET bootstrap itself lives in ScadaLink.Host + /// (see Component-ClusterInfrastructure.md). + /// + /// Registering the means a misconfigured + /// ScadaLink:Cluster section (e.g. MinNrOfMembers: 2 or a quorum + /// split-brain strategy) throws an the + /// first time is resolved, rather than booting + /// into a broken cluster. + /// + /// public static IServiceCollection AddClusterInfrastructure(this IServiceCollection services) { - // Phase 0: skeleton only + services.TryAddEnumerable( + ServiceDescriptor.Singleton, ClusterOptionsValidator>()); return services; } + /// + /// Reserved for cluster-infrastructure actor registration. This component does + /// not register any actors — the Akka.NET bootstrap and actor wiring live in + /// ScadaLink.Host. The method throws rather than silently returning + /// success so that any caller assuming this component registers actors fails + /// fast with a clear cause instead of failing later, far from here. + /// + /// Always thrown. public static IServiceCollection AddClusterInfrastructureActors(this IServiceCollection services) { - // Phase 0: placeholder for Akka actor registration - return services; + throw new NotImplementedException( + "ScadaLink.ClusterInfrastructure registers no actors. The Akka.NET actor system " + + "bootstrap and all cluster actor registration live in ScadaLink.Host " + + "(AkkaHostedService). Do not call AddClusterInfrastructureActors()."); } } diff --git a/tests/ScadaLink.ClusterInfrastructure.Tests/ClusterOptionsTests.cs b/tests/ScadaLink.ClusterInfrastructure.Tests/ClusterOptionsTests.cs index 178a6e0..0cd1146 100644 --- a/tests/ScadaLink.ClusterInfrastructure.Tests/ClusterOptionsTests.cs +++ b/tests/ScadaLink.ClusterInfrastructure.Tests/ClusterOptionsTests.cs @@ -15,6 +15,15 @@ public class ClusterOptionsTests Assert.Equal(TimeSpan.FromSeconds(2), options.HeartbeatInterval); Assert.Equal(TimeSpan.FromSeconds(10), options.FailureDetectionThreshold); Assert.Equal(1, options.MinNrOfMembers); + Assert.True(options.DownIfAlone); + } + + [Fact] + public void DownIfAlone_CanBeSet() + { + var options = new ClusterOptions { DownIfAlone = false }; + + Assert.False(options.DownIfAlone); } [Fact] diff --git a/tests/ScadaLink.ClusterInfrastructure.Tests/ClusterOptionsValidatorTests.cs b/tests/ScadaLink.ClusterInfrastructure.Tests/ClusterOptionsValidatorTests.cs new file mode 100644 index 0000000..eda4639 --- /dev/null +++ b/tests/ScadaLink.ClusterInfrastructure.Tests/ClusterOptionsValidatorTests.cs @@ -0,0 +1,119 @@ +using Microsoft.Extensions.Options; + +namespace ScadaLink.ClusterInfrastructure.Tests; + +/// +/// CI-004: Tests that rejects the +/// catastrophic misconfigurations the design doc warns against — a +/// MinNrOfMembers other than 1, an unsupported split-brain strategy, +/// empty seed nodes, and timings where the heartbeat is not below the +/// failure-detection threshold. +/// +public class ClusterOptionsValidatorTests +{ + private static ClusterOptions ValidOptions() => new() + { + SeedNodes = new List { "akka.tcp://scadalink@node1:8081", "akka.tcp://scadalink@node2:8081" }, + SplitBrainResolverStrategy = "keep-oldest", + StableAfter = TimeSpan.FromSeconds(15), + HeartbeatInterval = TimeSpan.FromSeconds(2), + FailureDetectionThreshold = TimeSpan.FromSeconds(10), + MinNrOfMembers = 1, + DownIfAlone = true + }; + + [Fact] + public void DefaultOptions_AreValid() + { + var result = new ClusterOptionsValidator().Validate(null, ValidOptions()); + + Assert.True(result.Succeeded, result.FailureMessage); + } + + [Fact] + public void MinNrOfMembers_NotOne_FailsValidation() + { + var options = ValidOptions(); + options.MinNrOfMembers = 2; + + var result = new ClusterOptionsValidator().Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("MinNrOfMembers", result.FailureMessage); + } + + [Theory] + [InlineData("keep-majority")] + [InlineData("static-quorum")] + [InlineData("nonsense")] + public void UnsupportedSplitBrainStrategy_FailsValidation(string strategy) + { + var options = ValidOptions(); + options.SplitBrainResolverStrategy = strategy; + + var result = new ClusterOptionsValidator().Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("SplitBrainResolverStrategy", result.FailureMessage); + } + + [Fact] + public void EmptySeedNodes_FailsValidation() + { + var options = ValidOptions(); + options.SeedNodes = new List(); + + var result = new ClusterOptionsValidator().Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("SeedNodes", result.FailureMessage); + } + + [Fact] + public void HeartbeatNotBelowFailureThreshold_FailsValidation() + { + var options = ValidOptions(); + options.HeartbeatInterval = TimeSpan.FromSeconds(10); + options.FailureDetectionThreshold = TimeSpan.FromSeconds(10); + + var result = new ClusterOptionsValidator().Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("HeartbeatInterval", result.FailureMessage); + } + + [Fact] + public void NonPositiveStableAfter_FailsValidation() + { + var options = ValidOptions(); + options.StableAfter = TimeSpan.Zero; + + var result = new ClusterOptionsValidator().Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("StableAfter", result.FailureMessage); + } + + [Fact] + public void Validate_AccumulatesAllFailures() + { + var options = new ClusterOptions + { + SeedNodes = new List(), + SplitBrainResolverStrategy = "keep-majority", + MinNrOfMembers = 2, + StableAfter = TimeSpan.Zero, + HeartbeatInterval = TimeSpan.FromSeconds(20), + FailureDetectionThreshold = TimeSpan.FromSeconds(10) + }; + + var result = new ClusterOptionsValidator().Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("SeedNodes", result.FailureMessage); + Assert.Contains("SplitBrainResolverStrategy", result.FailureMessage); + Assert.Contains("MinNrOfMembers", result.FailureMessage); + Assert.Contains("StableAfter", result.FailureMessage); + Assert.Contains("HeartbeatInterval", result.FailureMessage); + } +} diff --git a/tests/ScadaLink.ClusterInfrastructure.Tests/ScadaLink.ClusterInfrastructure.Tests.csproj b/tests/ScadaLink.ClusterInfrastructure.Tests/ScadaLink.ClusterInfrastructure.Tests.csproj index 0dfb468..efc3848 100644 --- a/tests/ScadaLink.ClusterInfrastructure.Tests/ScadaLink.ClusterInfrastructure.Tests.csproj +++ b/tests/ScadaLink.ClusterInfrastructure.Tests/ScadaLink.ClusterInfrastructure.Tests.csproj @@ -10,6 +10,8 @@ + + diff --git a/tests/ScadaLink.ClusterInfrastructure.Tests/ServiceCollectionExtensionsTests.cs b/tests/ScadaLink.ClusterInfrastructure.Tests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..b7a431b --- /dev/null +++ b/tests/ScadaLink.ClusterInfrastructure.Tests/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace ScadaLink.ClusterInfrastructure.Tests; + +/// +/// CI-002: Tests that the DI extension methods do real work rather than +/// silently returning success. +/// must register the so misconfiguration +/// fails fast, and the unimplemented actor-registration placeholder must fail +/// loudly rather than masquerade as a completed registration. +/// +public class ServiceCollectionExtensionsTests +{ + [Fact] + public void AddClusterInfrastructure_RegistersOptionsValidator() + { + var services = new ServiceCollection(); + + services.AddClusterInfrastructure(); + + var validators = services + .Where(d => d.ServiceType == typeof(IValidateOptions)) + .ToList(); + Assert.NotEmpty(validators); + + using var provider = services.BuildServiceProvider(); + var validator = provider.GetService>(); + Assert.IsType(validator); + } + + [Fact] + public void AddClusterInfrastructure_ValidatorRejectsBadOptionsAtResolution() + { + var services = new ServiceCollection(); + services.AddClusterInfrastructure(); + // A MinNrOfMembers of 2 blocks the cluster singleton after failover. + services.Configure(o => + { + o.SeedNodes = new List { "akka.tcp://scadalink@node1:8081" }; + o.MinNrOfMembers = 2; + }); + + using var provider = services.BuildServiceProvider(); + + var ex = Assert.Throws( + () => provider.GetRequiredService>().Value); + Assert.Contains("MinNrOfMembers", ex.Message); + } + + [Fact] + public void AddClusterInfrastructureActors_ThrowsRatherThanSilentlySucceeding() + { + var services = new ServiceCollection(); + + Assert.Throws(() => services.AddClusterInfrastructureActors()); + } +}