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.Tests/AddInstance/AuditInstanceServiceControlQueueAddress.cs b/src/ServiceControl.Config.Tests/AddInstance/AuditInstanceServiceControlQueueAddress.cs
new file mode 100644
index 0000000000..1dcd2a169d
--- /dev/null
+++ b/src/ServiceControl.Config.Tests/AddInstance/AuditInstanceServiceControlQueueAddress.cs
@@ -0,0 +1,229 @@
+namespace ServiceControl.Config.Tests.AddInstance
+{
+ using System.ComponentModel;
+ using NUnit.Framework;
+ using ServiceControl.Config.UI.InstanceAdd;
+
+ ///
+ /// Executable specification for the audit instance ServiceControl queue address feature
+ /// (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,
+ /// - each test is one example, named with "The one where ..." language.
+ ///
+ /// The tests observe the view model and its validator through INotifyDataErrorInfo -
+ /// the same mechanism the UI uses to block Save - and substitute the
+ /// GetInstalledErrorInstanceNames seam to simulate the error instances installed on
+ /// the machine.
+ ///
+ 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_require_a_manually_entered_queue_address_when_no_error_instance_is_detected
+ {
+ [Test]
+ public void The_one_where_no_error_instance_is_detected_and_save_is_blocked_until_the_remote_address_is_entered()
+ {
+ var viewModel = new ServiceControlAddViewModel
+ {
+ InstallErrorInstance = false,
+ InstallAuditInstance = true,
+ SubmitAttempted = true,
+ GetInstalledErrorInstanceNames = () => new string[0]
+ };
+
+ viewModel.NotifyOfPropertyChange(nameof(viewModel.ServiceControlQueueAddress));
+
+ var notifyErrorInfo = GetNotifyErrorInfo(viewModel);
+
+ // Nothing entered yet: must be blocked
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(viewModel.ShowServiceControlQueueAddressEntry, Is.True,
+ "The entry field must be shown so the user can supply the address of a remote error instance");
+ Assert.That(viewModel.ShowServiceControlQueueAddressSelection, Is.False);
+ Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Not.Empty,
+ "A validation error is expected until the user enters the error instance's queue address");
+ }
+
+ // User types the address of the error instance hosted on another machine
+ viewModel.ServiceControlQueueAddress = "Particular.ServiceControl.OnAnotherVM";
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(viewModel.ServiceControlQueueAddress, Is.EqualTo("Particular.ServiceControl.OnAnotherVM"));
+ Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Empty);
+ }
+ }
+
+ [Test]
+ public void The_one_where_the_entered_address_contains_whitespace_and_is_rejected()
+ {
+ var viewModel = new ServiceControlAddViewModel
+ {
+ InstallErrorInstance = false,
+ InstallAuditInstance = true,
+ SubmitAttempted = true,
+ GetInstalledErrorInstanceNames = () => new string[0]
+ };
+
+ viewModel.ServiceControlQueueAddress = "Particular ServiceControl";
+
+ var notifyErrorInfo = GetNotifyErrorInfo(viewModel);
+
+ Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Not.Empty,
+ "Instance names never contain whitespace, so a queue address with whitespace must be rejected");
+ }
+
+ [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);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(viewModel.ShowServiceControlQueueAddressEntry, Is.False);
+ Assert.That(notifyErrorInfo.GetErrors(nameof(viewModel.ServiceControlQueueAddress)), Is.Empty);
+ }
+ }
+ }
+
+ static INotifyDataErrorInfo GetNotifyErrorInfo(object vm) => vm as INotifyDataErrorInfo;
+ }
+}
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/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..2e0edd5bfe 100644
--- a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddView.xaml
+++ b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddView.xaml
@@ -299,6 +299,24 @@
+
+
+
+
+
+
+
diff --git a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs
index cc8bdb3d2a..0b9621251b 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,16 +8,21 @@
using System.Windows.Input;
using PropertyChanged;
using ServiceControl.Config.Extensions;
+ using ServiceControlInstaller.Engine.Instances;
using Validar;
using Xaml.Controls;
[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();
@@ -30,6 +35,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(ShowServiceControlQueueAddressEntry));
+ }
+ };
}
void ServiceControl_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
@@ -47,6 +64,40 @@ void ServiceControl_PropertyChanged(object sender, System.ComponentModel.Propert
public Func GetWindowsServiceNames { get; set; }
+ public Func GetInstalledErrorInstanceNames { get; set; }
+
+ public string[] ServiceControlQueueAddressOptions => GetInstalledErrorInstanceNames();
+
+ public string ServiceControlQueueAddress
+ {
+ get
+ {
+ if (InstallErrorInstance)
+ {
+ return ErrorInstanceName;
+ }
+
+ var installedErrorInstanceNames = GetInstalledErrorInstanceNames();
+
+ return installedErrorInstanceNames.Length == 1
+ ? installedErrorInstanceNames[0]
+ : serviceControlQueueAddress;
+ }
+ set => serviceControlQueueAddress = value;
+ }
+
+ string serviceControlQueueAddress;
+
+ public bool ShowServiceControlQueueAddressSelection =>
+ InstallAuditInstance
+ && !InstallErrorInstance
+ && GetInstalledErrorInstanceNames().Length > 1;
+
+ public bool ShowServiceControlQueueAddressEntry =>
+ InstallAuditInstance
+ && !InstallErrorInstance
+ && GetInstalledErrorInstanceNames().Length == 0;
+
public string ConventionName { get; set; }
public void OnConventionNameChanged()
diff --git a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModelValidator.cs b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModelValidator.cs
index 7a627d954a..50b27017a8 100644
--- a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModelValidator.cs
+++ b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModelValidator.cs
@@ -150,6 +150,13 @@ 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 for the audit instance to send messages to")
+ .MustNotContainWhitespace()
+ .WithMessage(string.Format(Validation.Validations.MSG_CANTCONTAINWHITESPACE, "Error instance queue address"))
+ .When(viewModel => viewModel.InstallAuditInstance && !viewModel.InstallErrorInstance);
+
RuleFor(x => x.AuditServiceAccount)
.NotEmpty()
.When(x => x.InstallAuditInstance && x.SubmitAttempted);