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);