From 1e6fd2e92c50d575c9b2dadec4bb40f0e9398a1e Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 21 Jul 2026 17:51:31 -0600 Subject: [PATCH 1/7] Add spec tests for ServiceControlQueueAddress on add instance Covers issue #4753, where SCMU does not set ServiceControl.Audit/ServiceControlQueueAddress when only adding an audit instance. The tests describe the expected behavior of ServiceControlAddViewModel: - Error + audit installed together: the queue address is the name of the error instance being installed, and no selection dropdown is shown. - Audit only with a single existing error instance: the address is auto-detected from it, no dropdown. - Audit only with multiple existing error instances: the dropdown is shown with all of them and Save is blocked until one is picked. - Audit only with no existing error instance: Save is blocked by a validation error. - Error instance only: no queue address validation error. --- .../ServiceControlQueueAddressTests.cs | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 src/ServiceControl.Config.Tests/AddInstance/ServiceControlQueueAddressTests.cs diff --git a/src/ServiceControl.Config.Tests/AddInstance/ServiceControlQueueAddressTests.cs b/src/ServiceControl.Config.Tests/AddInstance/ServiceControlQueueAddressTests.cs new file mode 100644 index 0000000000..68929e7acb --- /dev/null +++ b/src/ServiceControl.Config.Tests/AddInstance/ServiceControlQueueAddressTests.cs @@ -0,0 +1,176 @@ +namespace ServiceControl.Config.Tests.AddInstance +{ + using System.ComponentModel; + using NUnit.Framework; + using ServiceControl.Config.UI.InstanceAdd; + + /// + /// Spec for https://github.com/Particular/ServiceControl/issues/4753 + /// SCMU does not set ServiceControl.Audit/ServiceControlQueueAddress when only adding audit instances. + /// + /// Expected behavior: + /// - Installing error + audit together: queue address is the name of the error instance being installed. + /// - Installing audit only, one existing error instance on the machine: auto-detect and use its name. + /// - Installing audit only, multiple existing error instances: user must pick one from a dropdown + /// (dropdown only visible in this case); Save is blocked until a selection is made. + /// - Installing audit only, no existing error instance: Save is blocked by a validation error. + /// + public class ServiceControlQueueAddressTests + { + [Test] + public void Queue_address_is_the_new_error_instance_name_when_error_instance_is_installed_together() + { + var viewModel = new ServiceControlAddViewModel + { + InstallErrorInstance = true, + InstallAuditInstance = true, + SubmitAttempted = true, + // No pre-existing error instances on the machine + GetInstalledErrorInstanceNames = () => new string[0] + }; + + viewModel.ErrorInstanceName = "My.Error.Instance"; + + viewModel.NotifyOfPropertyChange(nameof(viewModel.ServiceControlQueueAddress)); + + var notifyErrorInfo = GetNotifyErrorInfo(viewModel); + + using (Assert.EnterMultipleScope()) + { + Assert.That(viewModel.ServiceControlQueueAddress, Is.EqualTo("My.Error.Instance")); + Assert.That(viewModel.ShowServiceControlQueueAddressSelection, Is.False); + Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Empty); + } + } + + [Test] + public void Queue_address_is_autodetected_when_adding_audit_only_and_a_single_error_instance_exists() + { + var viewModel = new ServiceControlAddViewModel + { + InstallErrorInstance = false, + InstallAuditInstance = true, + SubmitAttempted = true, + GetInstalledErrorInstanceNames = () => new string[] { "Particular.ServiceControl" } + }; + + viewModel.NotifyOfPropertyChange(nameof(viewModel.ServiceControlQueueAddress)); + + var notifyErrorInfo = GetNotifyErrorInfo(viewModel); + + using (Assert.EnterMultipleScope()) + { + Assert.That(viewModel.ServiceControlQueueAddress, Is.EqualTo("Particular.ServiceControl")); + Assert.That(viewModel.ShowServiceControlQueueAddressSelection, Is.False, "Dropdown must not show when there is only one existing error instance"); + Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Empty); + } + } + + [Test] + public void Queue_address_dropdown_is_shown_only_when_adding_audit_only_and_multiple_error_instances_exist() + { + var viewModel = new ServiceControlAddViewModel + { + InstallErrorInstance = false, + InstallAuditInstance = true, + GetInstalledErrorInstanceNames = () => new string[] { "Particular.ServiceControl", "Particular.ServiceControl.2" } + }; + + using (Assert.EnterMultipleScope()) + { + Assert.That(viewModel.ShowServiceControlQueueAddressSelection, Is.True); + Assert.That(viewModel.ServiceControlQueueAddressOptions, Is.EquivalentTo(new[] + { + "Particular.ServiceControl", + "Particular.ServiceControl.2" + })); + } + } + + [Test] + public void Queue_address_dropdown_is_not_shown_when_error_instance_is_installed_together_even_if_multiple_error_instances_exist() + { + var viewModel = new ServiceControlAddViewModel + { + InstallErrorInstance = true, + InstallAuditInstance = true, + GetInstalledErrorInstanceNames = () => new string[] { "Particular.ServiceControl", "Particular.ServiceControl.2" } + }; + + Assert.That(viewModel.ShowServiceControlQueueAddressSelection, Is.False); + } + + [Test] + public void Save_is_blocked_when_adding_audit_only_and_multiple_error_instances_exist_until_one_is_selected() + { + var viewModel = new ServiceControlAddViewModel + { + InstallErrorInstance = false, + InstallAuditInstance = true, + SubmitAttempted = true, + GetInstalledErrorInstanceNames = () => new string[] { "Particular.ServiceControl", "Particular.ServiceControl.2" } + }; + + viewModel.NotifyOfPropertyChange(nameof(viewModel.ServiceControlQueueAddress)); + + var notifyErrorInfo = GetNotifyErrorInfo(viewModel); + + // No selection made yet: must be blocked + Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Not.Empty, + "A validation error is expected until the user picks one of the existing error instances"); + + // User picks an instance from the dropdown + viewModel.ServiceControlQueueAddress = "Particular.ServiceControl.2"; + + using (Assert.EnterMultipleScope()) + { + Assert.That(viewModel.ServiceControlQueueAddress, Is.EqualTo("Particular.ServiceControl.2")); + Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Empty); + } + } + + [Test] + public void Save_is_blocked_when_adding_audit_only_and_no_error_instance_exists() + { + var viewModel = new ServiceControlAddViewModel + { + InstallErrorInstance = false, + InstallAuditInstance = true, + SubmitAttempted = true, + GetInstalledErrorInstanceNames = () => new string[0] + }; + + viewModel.NotifyOfPropertyChange(nameof(viewModel.ServiceControlQueueAddress)); + + var notifyErrorInfo = GetNotifyErrorInfo(viewModel); + + using (Assert.EnterMultipleScope()) + { + Assert.That(viewModel.ServiceControlQueueAddress, Is.Null.Or.Empty); + Assert.That(viewModel.ShowServiceControlQueueAddressSelection, Is.False); + Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Not.Empty, + "A validation error is expected so the user cannot proceed without an existing error instance to connect to"); + } + } + + [Test] + public void No_queue_address_validation_error_when_only_installing_an_error_instance() + { + var viewModel = new ServiceControlAddViewModel + { + InstallErrorInstance = true, + InstallAuditInstance = false, + SubmitAttempted = true, + GetInstalledErrorInstanceNames = () => new string[0] + }; + + viewModel.NotifyOfPropertyChange(nameof(viewModel.ServiceControlQueueAddress)); + + var notifyErrorInfo = GetNotifyErrorInfo(viewModel); + + Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Empty); + } + + static INotifyDataErrorInfo GetNotifyErrorInfo(object vm) => vm as INotifyDataErrorInfo; + } +} From 1c6a6ed0e1251650914ff4b65c7c58b52c08b07d Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 22 Jul 2026 08:54:40 -0600 Subject: [PATCH 2/7] Add spec doc for audit instance queue address Replaces the initial ServiceControlQueueAddressTests with an executable specification organized as feature > rule > examples, mirroring the new docs/specs/audit-instance-servicecontrol-queue-address.md. The spec captures the four rules for bug #4753 (SCMU does not set ServiceControl.Audit/ServiceControlQueueAddress when only adding an audit instance): the instance installed in the same session always wins, auto-detect when exactly one error instance exists, require an explicit choice when several exist, and block Save when none exist. These are outer-loop TDD tests and reference members that do not exist yet: GetInstalledErrorInstanceNames, ServiceControlQueueAddress, ServiceControlQueueAddressOptions, ShowServiceControlQueueAddressSelection. --- ...t-instance-servicecontrol-queue-address.md | 70 +++++++ ...AuditInstanceServiceControlQueueAddress.cs | 196 ++++++++++++++++++ .../ServiceControlQueueAddressTests.cs | 176 ---------------- 3 files changed, 266 insertions(+), 176 deletions(-) create mode 100644 docs/specs/audit-instance-servicecontrol-queue-address.md create mode 100644 src/ServiceControl.Config.Tests/AddInstance/AuditInstanceServiceControlQueueAddress.cs delete mode 100644 src/ServiceControl.Config.Tests/AddInstance/ServiceControlQueueAddressTests.cs diff --git a/docs/specs/audit-instance-servicecontrol-queue-address.md b/docs/specs/audit-instance-servicecontrol-queue-address.md new file mode 100644 index 0000000000..78b680d0b0 --- /dev/null +++ b/docs/specs/audit-instance-servicecontrol-queue-address.md @@ -0,0 +1,70 @@ +# Feature: Audit Instance ServiceControl Queue Address + +**As an operator adding instances through ServiceControl Management Utility (SCMU), I want every audit instance to be connected to a ServiceControl (error) instance so that messages the audit instance sends to the error instance always have a valid destination.** + +> Bug: [#4753 — SCMU does not set ServiceControlQueueAddress when only adding audit instances](https://github.com/Particular/ServiceControl/issues/4753). +> When the `ServiceControl.Audit/ServiceControlQueueAddress` setting is missing from +> `ServiceControl.Audit.exe.config`, the audit instance fails at runtime with +> ["no destination specified for message"](https://docs.particular.net/servicecontrol/troubleshooting#no-destination-specified-for-message). + +## Rules and Examples + +### Rule 1: Must address the audit instance to the error instance installed in the same session + +When the user installs an error instance and an audit instance together, the audit +instance's queue address is the name of the error instance being installed — never an +already-installed one. + +- **Example:** The one where both instances are installed together and the audit + instance's queue address is the new error instance's name. +- **Counter-example:** The one where other error instances already exist on the + machine, yet no choice is offered — the error instance being installed always wins. + +--- + +### Rule 2: Should auto-detect the existing error instance when adding an audit instance alone + +- **Example:** The one where exactly one error instance exists on the machine and its + name is used as the queue address without any user input (no dropdown shown). + +--- + +### Rule 3: Must require an explicit choice when multiple existing error instances are found + +Auto-detection cannot guess between several error instances — picking one silently +risks routing messages to the wrong instance. The choice dropdown is shown **only** in +this case. + +- **Example:** The one where two error instances exist and the dropdown offers both. +- **Example:** The one where Save is blocked until the user picks one of the detected + instances, and unblocked once a choice is made. + +--- + +### Rule 4: Must block installation when no error instance exists to connect to + +An audit instance without a reachable error instance is misconfigured by definition; +SCMU must not produce it. + +- **Example:** The one where no error instance exists, the user adds an audit instance + alone, and a validation error prevents the installation from proceeding. +- **Counter-example:** The one where only an error instance is being installed — the + queue address does not apply and no validation error is raised. + +## Resolved decisions (for implementation) + +- **Auto-detect source:** installed Windows error instances, discovered via + `InstanceFinder.ServiceControlInstances()`; exposed on the view model through a + `GetInstalledErrorInstanceNames` function seam (mirrors the existing + `GetWindowsServiceNames` pattern) so tests can substitute it. +- **Multiple instances found:** user must choose from a dropdown that is visible only + when adding an audit instance alone **and** more than one error instance is detected. +- **No instance found:** Save is blocked by a validation error; deploying with + PowerShell remains the path for advanced scenarios. +- **Acceptance tier:** view model + validator observed through `INotifyDataErrorInfo` + — the same mechanism the UI uses to block Save. A full SCMU end-to-end test (install + a Windows service, inspect the written config file) is not automatable in this + repository's test suites. +- **Out of scope:** registering the new audit instance as a remote of the existing + error instance (`AddRemoteInstance` is only called when both instances are installed + together) — candidate for a follow-up issue. diff --git a/src/ServiceControl.Config.Tests/AddInstance/AuditInstanceServiceControlQueueAddress.cs b/src/ServiceControl.Config.Tests/AddInstance/AuditInstanceServiceControlQueueAddress.cs new file mode 100644 index 0000000000..cc2105f8ab --- /dev/null +++ b/src/ServiceControl.Config.Tests/AddInstance/AuditInstanceServiceControlQueueAddress.cs @@ -0,0 +1,196 @@ +namespace ServiceControl.Config.Tests.AddInstance +{ + using System.ComponentModel; + using NUnit.Framework; + using ServiceControl.Config.UI.InstanceAdd; + + /// + /// Executable specification for docs/specs/audit-instance-servicecontrol-queue-address.md + /// (bug https://github.com/Particular/ServiceControl/issues/4753). + /// + /// Organized as feature > rule > examples: + /// - this outer class is the feature, + /// - each nested fixture is one rule from the spec, + /// - each test is one example, named with the spec's "The one where ..." language. + /// + /// These tests are the OUTER loop of a double-loop TDD process. They observe the view + /// model and its validator through INotifyDataErrorInfo - the same mechanism the UI + /// uses to block Save - and reference members that do not exist yet: + /// GetInstalledErrorInstanceNames, ServiceControlQueueAddress, + /// ServiceControlQueueAddressOptions, ShowServiceControlQueueAddressSelection. + /// + public class AuditInstanceServiceControlQueueAddress + { + [TestFixture] + public class Rule_1_Must_address_the_audit_instance_to_the_error_instance_installed_in_the_same_session + { + [Test] + public void The_one_where_both_instances_are_installed_together_and_the_new_error_instance_name_is_used() + { + var viewModel = new ServiceControlAddViewModel + { + InstallErrorInstance = true, + InstallAuditInstance = true, + SubmitAttempted = true, + // No pre-existing error instances on the machine + GetInstalledErrorInstanceNames = () => new string[0] + }; + + viewModel.ErrorInstanceName = "My.Error.Instance"; + + viewModel.NotifyOfPropertyChange(nameof(viewModel.ServiceControlQueueAddress)); + + var notifyErrorInfo = GetNotifyErrorInfo(viewModel); + + using (Assert.EnterMultipleScope()) + { + Assert.That(viewModel.ServiceControlQueueAddress, Is.EqualTo("My.Error.Instance")); + Assert.That(viewModel.ShowServiceControlQueueAddressSelection, Is.False); + Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Empty); + } + } + + [Test] + public void The_one_where_other_error_instances_already_exist_yet_no_choice_is_offered_because_the_instance_being_installed_wins() + { + var viewModel = new ServiceControlAddViewModel + { + InstallErrorInstance = true, + InstallAuditInstance = true, + GetInstalledErrorInstanceNames = () => new string[] { "Particular.ServiceControl", "Particular.ServiceControl.2" } + }; + + Assert.That(viewModel.ShowServiceControlQueueAddressSelection, Is.False); + } + } + + [TestFixture] + public class Rule_2_Should_auto_detect_the_existing_error_instance_when_adding_an_audit_instance_alone + { + [Test] + public void The_one_where_a_single_error_instance_exists_and_its_name_is_used_without_any_user_input() + { + var viewModel = new ServiceControlAddViewModel + { + InstallErrorInstance = false, + InstallAuditInstance = true, + SubmitAttempted = true, + GetInstalledErrorInstanceNames = () => new string[] { "Particular.ServiceControl" } + }; + + viewModel.NotifyOfPropertyChange(nameof(viewModel.ServiceControlQueueAddress)); + + var notifyErrorInfo = GetNotifyErrorInfo(viewModel); + + using (Assert.EnterMultipleScope()) + { + Assert.That(viewModel.ServiceControlQueueAddress, Is.EqualTo("Particular.ServiceControl")); + Assert.That(viewModel.ShowServiceControlQueueAddressSelection, Is.False, "Dropdown must not show when there is only one existing error instance"); + Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Empty); + } + } + } + + [TestFixture] + public class Rule_3_Must_require_an_explicit_choice_when_multiple_existing_error_instances_are_found + { + [Test] + public void The_one_where_two_error_instances_exist_and_the_dropdown_offers_both() + { + var viewModel = new ServiceControlAddViewModel + { + InstallErrorInstance = false, + InstallAuditInstance = true, + GetInstalledErrorInstanceNames = () => new string[] { "Particular.ServiceControl", "Particular.ServiceControl.2" } + }; + + using (Assert.EnterMultipleScope()) + { + Assert.That(viewModel.ShowServiceControlQueueAddressSelection, Is.True); + Assert.That(viewModel.ServiceControlQueueAddressOptions, Is.EquivalentTo(new[] + { + "Particular.ServiceControl", + "Particular.ServiceControl.2" + })); + } + } + + [Test] + public void The_one_where_save_is_blocked_until_the_user_picks_one_of_the_detected_instances() + { + var viewModel = new ServiceControlAddViewModel + { + InstallErrorInstance = false, + InstallAuditInstance = true, + SubmitAttempted = true, + GetInstalledErrorInstanceNames = () => new string[] { "Particular.ServiceControl", "Particular.ServiceControl.2" } + }; + + viewModel.NotifyOfPropertyChange(nameof(viewModel.ServiceControlQueueAddress)); + + var notifyErrorInfo = GetNotifyErrorInfo(viewModel); + + // No selection made yet: must be blocked + Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Not.Empty, + "A validation error is expected until the user picks one of the existing error instances"); + + // User picks an instance from the dropdown + viewModel.ServiceControlQueueAddress = "Particular.ServiceControl.2"; + + using (Assert.EnterMultipleScope()) + { + Assert.That(viewModel.ServiceControlQueueAddress, Is.EqualTo("Particular.ServiceControl.2")); + Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Empty); + } + } + } + + [TestFixture] + public class Rule_4_Must_block_installation_when_no_error_instance_exists_to_connect_to + { + [Test] + public void The_one_where_no_error_instance_exists_and_a_validation_error_prevents_the_installation_from_proceeding() + { + var viewModel = new ServiceControlAddViewModel + { + InstallErrorInstance = false, + InstallAuditInstance = true, + SubmitAttempted = true, + GetInstalledErrorInstanceNames = () => new string[0] + }; + + viewModel.NotifyOfPropertyChange(nameof(viewModel.ServiceControlQueueAddress)); + + var notifyErrorInfo = GetNotifyErrorInfo(viewModel); + + using (Assert.EnterMultipleScope()) + { + Assert.That(viewModel.ServiceControlQueueAddress, Is.Null.Or.Empty); + Assert.That(viewModel.ShowServiceControlQueueAddressSelection, Is.False); + Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Not.Empty, + "A validation error is expected so the user cannot proceed without an existing error instance to connect to"); + } + } + + [Test] + public void The_one_where_only_an_error_instance_is_installed_and_the_queue_address_does_not_apply() + { + var viewModel = new ServiceControlAddViewModel + { + InstallErrorInstance = true, + InstallAuditInstance = false, + SubmitAttempted = true, + GetInstalledErrorInstanceNames = () => new string[0] + }; + + viewModel.NotifyOfPropertyChange(nameof(viewModel.ServiceControlQueueAddress)); + + var notifyErrorInfo = GetNotifyErrorInfo(viewModel); + + Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Empty); + } + } + + static INotifyDataErrorInfo GetNotifyErrorInfo(object vm) => vm as INotifyDataErrorInfo; + } +} diff --git a/src/ServiceControl.Config.Tests/AddInstance/ServiceControlQueueAddressTests.cs b/src/ServiceControl.Config.Tests/AddInstance/ServiceControlQueueAddressTests.cs deleted file mode 100644 index 68929e7acb..0000000000 --- a/src/ServiceControl.Config.Tests/AddInstance/ServiceControlQueueAddressTests.cs +++ /dev/null @@ -1,176 +0,0 @@ -namespace ServiceControl.Config.Tests.AddInstance -{ - using System.ComponentModel; - using NUnit.Framework; - using ServiceControl.Config.UI.InstanceAdd; - - /// - /// Spec for https://github.com/Particular/ServiceControl/issues/4753 - /// SCMU does not set ServiceControl.Audit/ServiceControlQueueAddress when only adding audit instances. - /// - /// Expected behavior: - /// - Installing error + audit together: queue address is the name of the error instance being installed. - /// - Installing audit only, one existing error instance on the machine: auto-detect and use its name. - /// - Installing audit only, multiple existing error instances: user must pick one from a dropdown - /// (dropdown only visible in this case); Save is blocked until a selection is made. - /// - Installing audit only, no existing error instance: Save is blocked by a validation error. - /// - public class ServiceControlQueueAddressTests - { - [Test] - public void Queue_address_is_the_new_error_instance_name_when_error_instance_is_installed_together() - { - var viewModel = new ServiceControlAddViewModel - { - InstallErrorInstance = true, - InstallAuditInstance = true, - SubmitAttempted = true, - // No pre-existing error instances on the machine - GetInstalledErrorInstanceNames = () => new string[0] - }; - - viewModel.ErrorInstanceName = "My.Error.Instance"; - - viewModel.NotifyOfPropertyChange(nameof(viewModel.ServiceControlQueueAddress)); - - var notifyErrorInfo = GetNotifyErrorInfo(viewModel); - - using (Assert.EnterMultipleScope()) - { - Assert.That(viewModel.ServiceControlQueueAddress, Is.EqualTo("My.Error.Instance")); - Assert.That(viewModel.ShowServiceControlQueueAddressSelection, Is.False); - Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Empty); - } - } - - [Test] - public void Queue_address_is_autodetected_when_adding_audit_only_and_a_single_error_instance_exists() - { - var viewModel = new ServiceControlAddViewModel - { - InstallErrorInstance = false, - InstallAuditInstance = true, - SubmitAttempted = true, - GetInstalledErrorInstanceNames = () => new string[] { "Particular.ServiceControl" } - }; - - viewModel.NotifyOfPropertyChange(nameof(viewModel.ServiceControlQueueAddress)); - - var notifyErrorInfo = GetNotifyErrorInfo(viewModel); - - using (Assert.EnterMultipleScope()) - { - Assert.That(viewModel.ServiceControlQueueAddress, Is.EqualTo("Particular.ServiceControl")); - Assert.That(viewModel.ShowServiceControlQueueAddressSelection, Is.False, "Dropdown must not show when there is only one existing error instance"); - Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Empty); - } - } - - [Test] - public void Queue_address_dropdown_is_shown_only_when_adding_audit_only_and_multiple_error_instances_exist() - { - var viewModel = new ServiceControlAddViewModel - { - InstallErrorInstance = false, - InstallAuditInstance = true, - GetInstalledErrorInstanceNames = () => new string[] { "Particular.ServiceControl", "Particular.ServiceControl.2" } - }; - - using (Assert.EnterMultipleScope()) - { - Assert.That(viewModel.ShowServiceControlQueueAddressSelection, Is.True); - Assert.That(viewModel.ServiceControlQueueAddressOptions, Is.EquivalentTo(new[] - { - "Particular.ServiceControl", - "Particular.ServiceControl.2" - })); - } - } - - [Test] - public void Queue_address_dropdown_is_not_shown_when_error_instance_is_installed_together_even_if_multiple_error_instances_exist() - { - var viewModel = new ServiceControlAddViewModel - { - InstallErrorInstance = true, - InstallAuditInstance = true, - GetInstalledErrorInstanceNames = () => new string[] { "Particular.ServiceControl", "Particular.ServiceControl.2" } - }; - - Assert.That(viewModel.ShowServiceControlQueueAddressSelection, Is.False); - } - - [Test] - public void Save_is_blocked_when_adding_audit_only_and_multiple_error_instances_exist_until_one_is_selected() - { - var viewModel = new ServiceControlAddViewModel - { - InstallErrorInstance = false, - InstallAuditInstance = true, - SubmitAttempted = true, - GetInstalledErrorInstanceNames = () => new string[] { "Particular.ServiceControl", "Particular.ServiceControl.2" } - }; - - viewModel.NotifyOfPropertyChange(nameof(viewModel.ServiceControlQueueAddress)); - - var notifyErrorInfo = GetNotifyErrorInfo(viewModel); - - // No selection made yet: must be blocked - Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Not.Empty, - "A validation error is expected until the user picks one of the existing error instances"); - - // User picks an instance from the dropdown - viewModel.ServiceControlQueueAddress = "Particular.ServiceControl.2"; - - using (Assert.EnterMultipleScope()) - { - Assert.That(viewModel.ServiceControlQueueAddress, Is.EqualTo("Particular.ServiceControl.2")); - Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Empty); - } - } - - [Test] - public void Save_is_blocked_when_adding_audit_only_and_no_error_instance_exists() - { - var viewModel = new ServiceControlAddViewModel - { - InstallErrorInstance = false, - InstallAuditInstance = true, - SubmitAttempted = true, - GetInstalledErrorInstanceNames = () => new string[0] - }; - - viewModel.NotifyOfPropertyChange(nameof(viewModel.ServiceControlQueueAddress)); - - var notifyErrorInfo = GetNotifyErrorInfo(viewModel); - - using (Assert.EnterMultipleScope()) - { - Assert.That(viewModel.ServiceControlQueueAddress, Is.Null.Or.Empty); - Assert.That(viewModel.ShowServiceControlQueueAddressSelection, Is.False); - Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Not.Empty, - "A validation error is expected so the user cannot proceed without an existing error instance to connect to"); - } - } - - [Test] - public void No_queue_address_validation_error_when_only_installing_an_error_instance() - { - var viewModel = new ServiceControlAddViewModel - { - InstallErrorInstance = true, - InstallAuditInstance = false, - SubmitAttempted = true, - GetInstalledErrorInstanceNames = () => new string[0] - }; - - viewModel.NotifyOfPropertyChange(nameof(viewModel.ServiceControlQueueAddress)); - - var notifyErrorInfo = GetNotifyErrorInfo(viewModel); - - Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Empty); - } - - static INotifyDataErrorInfo GetNotifyErrorInfo(object vm) => vm as INotifyDataErrorInfo; - } -} From 6b6d37b740602b17b0b876933b1b83bd8e33fc42 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 22 Jul 2026 12:48:33 -0700 Subject: [PATCH 3/7] Rule 1: address audit instance to the error instance installed in the same session ServiceControlQueueAddress resolves to ErrorInstanceName when an error instance is being installed. Adds the GetInstalledErrorInstanceNames seam (mirrors GetWindowsServiceNames) and naive stubs for the members Rules 2-4 will drive. Suppresses IDE0017 in the test project: executable specs assign properties after construction to mirror user interaction order. --- src/ServiceControl.Config.Tests/.editorconfig | 4 ++++ .../InstanceAdd/ServiceControlAddViewModel.cs | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/ServiceControl.Config.Tests/.editorconfig b/src/ServiceControl.Config.Tests/.editorconfig index 5f68a610b3..c5410d8c02 100644 --- a/src/ServiceControl.Config.Tests/.editorconfig +++ b/src/ServiceControl.Config.Tests/.editorconfig @@ -2,3 +2,7 @@ # Justification: Test project dotnet_diagnostic.CA2007.severity = none + +# Justification: Executable specifications intentionally assign properties after the +# object initializer to mirror user interaction order (e.g. typing a name after load) +dotnet_diagnostic.IDE0017.severity = none diff --git a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs index cc8bdb3d2a..d936c5210f 100644 --- a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs +++ b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs @@ -1,4 +1,4 @@ -namespace ServiceControl.Config.UI.InstanceAdd +namespace ServiceControl.Config.UI.InstanceAdd { using System; using System.Collections.Generic; @@ -8,6 +8,7 @@ using System.Windows.Input; using PropertyChanged; using ServiceControl.Config.Extensions; + using ServiceControlInstaller.Engine.Instances; using Validar; using Xaml.Controls; @@ -18,6 +19,7 @@ public ServiceControlAddViewModel() { DisplayName = "ADD SERVICECONTROL"; GetWindowsServiceNames = () => ServiceController.GetServices().Select(windowsService => windowsService.ServiceName).ToArray(); + GetInstalledErrorInstanceNames = () => InstanceFinder.ServiceControlInstances().Select(instance => instance.Name).ToArray(); ConventionName = "Particular.ServiceControl"; OnConventionNameChanged(); @@ -47,6 +49,20 @@ void ServiceControl_PropertyChanged(object sender, System.ComponentModel.Propert public Func GetWindowsServiceNames { get; set; } + public Func GetInstalledErrorInstanceNames { get; set; } + + public string[] ServiceControlQueueAddressOptions => null; + + public string ServiceControlQueueAddress + { + get => InstallErrorInstance ? ErrorInstanceName : serviceControlQueueAddress; + set => serviceControlQueueAddress = value; + } + + string serviceControlQueueAddress; + + public bool ShowServiceControlQueueAddressSelection => false; + public string ConventionName { get; set; } public void OnConventionNameChanged() From 52942fd9c8b6393f1c5bc76b15a0836043331c24 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 22 Jul 2026 12:50:01 -0700 Subject: [PATCH 4/7] Rule 2: auto-detect the existing error instance when adding an audit instance alone When no error instance is being installed and exactly one is detected on the machine, its name is used as the queue address without user input. --- .../UI/InstanceAdd/ServiceControlAddViewModel.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs index d936c5210f..2b22f70596 100644 --- a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs +++ b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs @@ -55,7 +55,19 @@ void ServiceControl_PropertyChanged(object sender, System.ComponentModel.Propert public string ServiceControlQueueAddress { - get => InstallErrorInstance ? ErrorInstanceName : serviceControlQueueAddress; + get + { + if (InstallErrorInstance) + { + return ErrorInstanceName; + } + + var installedErrorInstanceNames = GetInstalledErrorInstanceNames(); + + return installedErrorInstanceNames.Length == 1 + ? installedErrorInstanceNames[0] + : serviceControlQueueAddress; + } set => serviceControlQueueAddress = value; } From f8b08777bf30b911b5902481b5d9b664b50cc48d Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 22 Jul 2026 12:51:33 -0700 Subject: [PATCH 5/7] Rules 3-4: require explicit choice among multiple error instances, block when none exist The dropdown options and its visibility (audit-only + 2 or more detected) come from the installed-instance seam. A NotEmpty validation rule on ServiceControlQueueAddress blocks Save for audit-only adds until an address resolves - covering both the multiple-instances case (until the user picks) and the none-found case. Error-only installs are exempt. --- .../UI/InstanceAdd/ServiceControlAddViewModel.cs | 7 +++++-- .../UI/InstanceAdd/ServiceControlAddViewModelValidator.cs | 5 +++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs index 2b22f70596..c6a53f470e 100644 --- a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs +++ b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs @@ -51,7 +51,7 @@ void ServiceControl_PropertyChanged(object sender, System.ComponentModel.Propert public Func GetInstalledErrorInstanceNames { get; set; } - public string[] ServiceControlQueueAddressOptions => null; + public string[] ServiceControlQueueAddressOptions => GetInstalledErrorInstanceNames(); public string ServiceControlQueueAddress { @@ -73,7 +73,10 @@ public string ServiceControlQueueAddress string serviceControlQueueAddress; - public bool ShowServiceControlQueueAddressSelection => false; + public bool ShowServiceControlQueueAddressSelection => + InstallAuditInstance + && !InstallErrorInstance + && GetInstalledErrorInstanceNames().Length > 1; public string ConventionName { get; set; } diff --git a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModelValidator.cs b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModelValidator.cs index 7a627d954a..41aac63ee0 100644 --- a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModelValidator.cs +++ b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModelValidator.cs @@ -150,6 +150,11 @@ public ServiceControlAddViewModelValidator() .WithMessage("Audit instance name is already in use") .When(viewModel => viewModel.InstallAuditInstance); + RuleFor(viewModel => viewModel.ServiceControlQueueAddress) + .NotEmpty() + .WithMessage("An existing error instance must be selected to receive audit data") + .When(viewModel => viewModel.InstallAuditInstance && !viewModel.InstallErrorInstance); + RuleFor(x => x.AuditServiceAccount) .NotEmpty() .When(x => x.InstallAuditInstance && x.SubmitAttempted); From a6a1453926c63b4e2a6d9757df28e4e9ad8fb536 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 22 Jul 2026 12:53:09 -0700 Subject: [PATCH 6/7] Wire resolved queue address into the installer and add the selection UI The attachment writes viewModel.ServiceControlQueueAddress instead of the null-conditional expression that produced an empty string for audit-only adds - the root cause of #4753, since empty values are silently skipped when writing the config file. The audit section gets a ComboBox (visible only when multiple error instances are detected) and a warning for the none-found case. Explicit change notifications are raised for the derived computed properties because the install checkboxes live on the base class, outside Fody's dependency analysis. --- .../InstanceAdd/ServiceControlAddAttachment.cs | 2 +- .../UI/InstanceAdd/ServiceControlAddView.xaml | 15 +++++++++++++++ .../InstanceAdd/ServiceControlAddViewModel.cs | 17 +++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddAttachment.cs b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddAttachment.cs index 1d3c08009d..5c9863dd03 100644 --- a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddAttachment.cs +++ b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddAttachment.cs @@ -100,7 +100,7 @@ async Task Add() auditNewInstance.AuditRetentionPeriod = viewModel.ServiceControlAudit.AuditRetentionPeriod; auditNewInstance.ServiceAccount = viewModel.ServiceControlAudit.ServiceAccount; auditNewInstance.ServiceAccountPwd = viewModel.ServiceControlAudit.Password; - auditNewInstance.ServiceControlQueueAddress = serviceControlNewInstance == null ? string.Empty : serviceControlNewInstance.InstanceName; + auditNewInstance.ServiceControlQueueAddress = viewModel.ServiceControlQueueAddress; auditNewInstance.EnableFullTextSearchOnBodies = viewModel.ServiceControlAudit.EnableFullTextSearchOnBodies.Value; } diff --git a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddView.xaml b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddView.xaml index 9ace3fbd7a..a1d1739098 100644 --- a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddView.xaml +++ b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddView.xaml @@ -299,6 +299,21 @@ + + + + diff --git a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs index c6a53f470e..5a4bf21aa4 100644 --- a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs +++ b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs @@ -32,6 +32,18 @@ public ServiceControlAddViewModel() ServiceControl.PropertyChanged += ServiceControl_PropertyChanged; ServiceControlAudit.PropertyChanged += ServiceControl_PropertyChanged; + PropertyChanged += (_, e) => + { + // InstallErrorInstance/InstallAuditInstance live on the base class, so Fody + // cannot infer that these derived computed properties depend on them + if (e.PropertyName is nameof(InstallErrorInstance) or nameof(InstallAuditInstance)) + { + NotifyOfPropertyChange(nameof(ServiceControlQueueAddress)); + NotifyOfPropertyChange(nameof(ServiceControlQueueAddressOptions)); + NotifyOfPropertyChange(nameof(ShowServiceControlQueueAddressSelection)); + NotifyOfPropertyChange(nameof(ShowNoErrorInstanceFoundWarning)); + } + }; } void ServiceControl_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) @@ -78,6 +90,11 @@ public string ServiceControlQueueAddress && !InstallErrorInstance && GetInstalledErrorInstanceNames().Length > 1; + public bool ShowNoErrorInstanceFoundWarning => + InstallAuditInstance + && !InstallErrorInstance + && GetInstalledErrorInstanceNames().Length == 0; + public string ConventionName { get; set; } public void OnConventionNameChanged() From c3e9bd9a2b8930fe8891425ab11d872d48e3132e Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 22 Jul 2026 13:02:23 -0700 Subject: [PATCH 7/7] Make construction-time unique-naming injectable for testability The constructor runs the name-uniquing convention against the machine's real Windows services before tests can substitute the GetWindowsServiceNames property seam, so screen-loaded tests asserting convention-derived paths failed on any machine with actual ServiceControl instances installed. An optional constructor parameter lets those tests inject a fake service list; Autofac supplies the default (real) implementation in production since the parameter is unresolvable and optional. --- .../ServiceControlAddScreenLoadedTests.cs | 6 +++--- .../UI/InstanceAdd/ServiceControlAddViewModel.cs | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/ServiceControl.Config.Tests/ServiceControlAddScreenLoadedTests.cs b/src/ServiceControl.Config.Tests/ServiceControlAddScreenLoadedTests.cs index 118d0bc687..3c97a982ec 100644 --- a/src/ServiceControl.Config.Tests/ServiceControlAddScreenLoadedTests.cs +++ b/src/ServiceControl.Config.Tests/ServiceControlAddScreenLoadedTests.cs @@ -153,7 +153,7 @@ public void Database_maintenance_port_number_are_set_to_defaults_with_no_validat [Test] public void Destination_path_is_null() { - var viewModel = new ServiceControlAddViewModel(); + var viewModel = new ServiceControlAddViewModel(() => []); var errorInfo = (INotifyDataErrorInfo)viewModel; @@ -169,7 +169,7 @@ public void Destination_path_is_null() [Test] public void Log_path_is_null() { - var viewModel = new ServiceControlAddViewModel(); + var viewModel = new ServiceControlAddViewModel(() => []); var errorInfo = (INotifyDataErrorInfo)viewModel; @@ -186,7 +186,7 @@ public void Log_path_is_null() [Test] public void Database_path_is_null() { - var viewModel = new ServiceControlAddViewModel(); + var viewModel = new ServiceControlAddViewModel(() => []); var errorInfo = (INotifyDataErrorInfo)viewModel; diff --git a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs index 5a4bf21aa4..ce26263a1b 100644 --- a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs +++ b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs @@ -15,10 +15,13 @@ namespace ServiceControl.Config.UI.InstanceAdd [InjectValidation] public class ServiceControlAddViewModel : ServiceControlEditorViewModel { - public ServiceControlAddViewModel() + // The constructor runs the unique-name convention against the machine's Windows + // services, so tests inject a fake here to stay environment-independent; the + // GetWindowsServiceNames property seam is set too late for construction-time logic + public ServiceControlAddViewModel(Func getWindowsServiceNames = null) { DisplayName = "ADD SERVICECONTROL"; - GetWindowsServiceNames = () => ServiceController.GetServices().Select(windowsService => windowsService.ServiceName).ToArray(); + GetWindowsServiceNames = getWindowsServiceNames ?? (() => ServiceController.GetServices().Select(windowsService => windowsService.ServiceName).ToArray()); GetInstalledErrorInstanceNames = () => InstanceFinder.ServiceControlInstances().Select(instance => instance.Name).ToArray(); ConventionName = "Particular.ServiceControl"; OnConventionNameChanged();