using JdeScoping.ConfigManager.Constants; using JdeScoping.ConfigManager.Models; using JdeScoping.ConfigManager.Services; using JdeScoping.ConfigManager.Services.SecureStore; using JdeScoping.ConfigManager.ViewModels; using JdeScoping.ConfigManager.ViewModels.Forms; using JdeScoping.DataSync.Configuration; using Microsoft.Extensions.Logging; using NSubstitute; namespace JdeScoping.ConfigManager.Tests.ViewModels; public class MainWindowViewModelTests { private readonly IFileSystem _fileSystem; private readonly IConfigFileService _configFileService; private readonly IValidationService _validationService; private readonly IBackupService _backupService; private readonly IAutoDiscoveryService _autoDiscoveryService; private readonly IDialogService _dialogService; private readonly ISecureStoreManager _secureStoreManager; private readonly IClipboardService _clipboardService; private readonly IRuntimeConfigValidationService _runtimeValidationService; private readonly IConnectionTestService _connectionTestService; private readonly ILogger _logger; public MainWindowViewModelTests() { _fileSystem = Substitute.For(); _configFileService = Substitute.For(); _validationService = Substitute.For(); _backupService = Substitute.For(); _autoDiscoveryService = Substitute.For(); _dialogService = Substitute.For(); _secureStoreManager = Substitute.For(); _clipboardService = Substitute.For(); _runtimeValidationService = Substitute.For(); _connectionTestService = Substitute.For(); _logger = Substitute.For>(); _validationService.ValidateAppSettings(Arg.Any()) .Returns(new ValidationResult()); _validationService.ValidatePipelines(Arg.Any>()) .Returns(new ValidationResult()); } [Fact] public void SelectingDataSyncNode_LoadsDataSyncFormViewModel() { // Arrange var config = new ConfigModel { DataSync = new DataSyncSection { MaxDegreeOfParallelism = 8 } }; var sut = CreateViewModel(); sut.LoadConfigForTesting(config, null); var dataSyncNode = sut.TreeNodes .SelectMany(n => n.Children) .First(n => n.SectionKey == "DataSync"); // Act sut.SelectedNode = dataSyncNode; // Assert sut.SelectedFormViewModel.ShouldBeOfType(); ((DataSyncFormViewModel)sut.SelectedFormViewModel!).MaxDegreeOfParallelism.ShouldBe(8); } [Fact] public void SelectingDataAccessNode_LoadsDataAccessFormViewModel() { // Arrange var config = new ConfigModel { DataAccess = new DataAccessSection { ProductionSchema = "custom" } }; var sut = CreateViewModel(); sut.LoadConfigForTesting(config, null); var dataAccessNode = sut.TreeNodes .SelectMany(n => n.Children) .First(n => n.SectionKey == "DataAccess"); // Act sut.SelectedNode = dataAccessNode; // Assert sut.SelectedFormViewModel.ShouldBeOfType(); ((DataAccessFormViewModel)sut.SelectedFormViewModel!).ProductionSchema.ShouldBe("custom"); } [Fact] public void SelectingAuthNode_LoadsAuthFormViewModel() { // Arrange var config = new ConfigModel { Auth = new AuthSection { CookieName = "TestCookie" } }; var sut = CreateViewModel(); sut.LoadConfigForTesting(config, null); var authNode = sut.TreeNodes .SelectMany(n => n.Children) .First(n => n.SectionKey == "Auth"); // Act sut.SelectedNode = authNode; // Assert sut.SelectedFormViewModel.ShouldBeOfType(); ((AuthFormViewModel)sut.SelectedFormViewModel!).CookieName.ShouldBe("TestCookie"); } [Fact] public void SelectingLdapNode_LoadsLdapFormViewModel() { // Arrange var config = new ConfigModel { Ldap = new LdapSection { GroupDn = "CN=TestGroup" } }; var sut = CreateViewModel(); sut.LoadConfigForTesting(config, null); var ldapNode = sut.TreeNodes .SelectMany(n => n.Children) .First(n => n.SectionKey == "Ldap"); // Act sut.SelectedNode = ldapNode; // Assert sut.SelectedFormViewModel.ShouldBeOfType(); ((LdapFormViewModel)sut.SelectedFormViewModel!).GroupDn.ShouldBe("CN=TestGroup"); } [Fact] public void SelectingSearchNode_LoadsSearchFormViewModel() { // Arrange var config = new ConfigModel { Search = new SearchSection { MaxResultRows = 50000 } }; var sut = CreateViewModel(); sut.LoadConfigForTesting(config, null); var searchNode = sut.TreeNodes .SelectMany(n => n.Children) .First(n => n.SectionKey == "Search"); // Act sut.SelectedNode = searchNode; // Assert sut.SelectedFormViewModel.ShouldBeOfType(); ((SearchFormViewModel)sut.SelectedFormViewModel!).MaxResultRows.ShouldBe(50000); } [Fact] public void SelectingExcelExportNode_LoadsExcelExportFormViewModel() { // Arrange var config = new ConfigModel { ExcelExport = new ExcelExportSection { TimezoneId = "America/New_York" } }; var sut = CreateViewModel(); sut.LoadConfigForTesting(config, null); var excelNode = sut.TreeNodes .SelectMany(n => n.Children) .First(n => n.SectionKey == "ExcelExport"); // Act sut.SelectedNode = excelNode; // Assert sut.SelectedFormViewModel.ShouldBeOfType(); ((ExcelExportFormViewModel)sut.SelectedFormViewModel!).SelectedTimezone.ShouldBe("America/New_York"); } [Fact] public void SelectingPipelineNode_LoadsPipelineEditorViewModel() { // Arrange var config = new ConfigModel(); var pipelines = new Dictionary { ["WorkOrders"] = new EtlPipelineConfig { Name = "WorkOrders", Source = new SourceElement { Connection = "jde", Query = "SELECT * FROM WO" }, Destination = new DestinationElement { Table = "WorkOrder_Curr" } } }; var sut = CreateViewModel(); sut.LoadConfigForTesting(config, pipelines); var pipelineNode = sut.TreeNodes .SelectMany(n => n.Children) .First(n => n.SectionKey == "WorkOrders"); // Act sut.SelectedNode = pipelineNode; // Assert sut.SelectedFormViewModel.ShouldBeOfType(); var pipelineEditor = (PipelineEditorViewModel)sut.SelectedFormViewModel!; pipelineEditor.Name.ShouldBe("WorkOrders"); pipelineEditor.Source.Connection.ShouldBe("jde"); } [Fact] public void ModifyingFormProperty_SetsHasUnsavedChanges() { // Arrange var config = new ConfigModel(); var sut = CreateViewModel(); sut.LoadConfigForTesting(config, null); var dataSyncNode = sut.TreeNodes .SelectMany(n => n.Children) .First(n => n.SectionKey == "DataSync"); sut.SelectedNode = dataSyncNode; // Act ((DataSyncFormViewModel)sut.SelectedFormViewModel!).BatchSize = 10000; // Assert sut.HasUnsavedChanges.ShouldBeTrue(); } [Fact] public void ModifyingFormProperty_MarksNodeAsModified() { // Arrange var config = new ConfigModel(); var sut = CreateViewModel(); sut.LoadConfigForTesting(config, null); var dataSyncNode = sut.TreeNodes .SelectMany(n => n.Children) .First(n => n.SectionKey == "DataSync"); sut.SelectedNode = dataSyncNode; // Act ((DataSyncFormViewModel)sut.SelectedFormViewModel!).MaxDegreeOfParallelism = 16; // Assert dataSyncNode.IsModified.ShouldBeTrue(); } [Fact] public void SelectingFolderNode_SetsSelectedFormViewModelToNull() { // Arrange var config = new ConfigModel(); var sut = CreateViewModel(); sut.LoadConfigForTesting(config, null); var folderNode = sut.TreeNodes.First(); // Settings folder // Act sut.SelectedNode = folderNode; // Assert sut.SelectedFormViewModel.ShouldBeNull(); } [Fact] public void SelectingNull_SetsSelectedFormViewModelToNull() { // Arrange var config = new ConfigModel(); var sut = CreateViewModel(); sut.LoadConfigForTesting(config, null); var dataSyncNode = sut.TreeNodes .SelectMany(n => n.Children) .First(n => n.SectionKey == "DataSync"); sut.SelectedNode = dataSyncNode; // Act sut.SelectedNode = null; // Assert sut.SelectedFormViewModel.ShouldBeNull(); } [Fact] public void LoadConfigForTesting_BuildsTreeNodes() { // Arrange var config = new ConfigModel(); var sut = CreateViewModel(); // Act sut.LoadConfigForTesting(config, null); // Assert // Without a configured/open SecureStore, only Settings and Pipelines appear sut.TreeNodes.Count.ShouldBe(2); // Settings, Pipelines (no Secure Store when not configured) sut.TreeNodes[0].Name.ShouldBe("Settings"); sut.TreeNodes[0].Children.Count.ShouldBe(7); // ConnectionStrings, DataSync, DataAccess, Auth, Ldap, Search, ExcelExport sut.TreeNodes[1].Name.ShouldBe("Pipelines"); } [Fact] public void LoadConfigForTesting_WithPipelines_BuildsPipelineNodes() { // Arrange var config = new ConfigModel(); var pipelines = new Dictionary { ["Pipeline1"] = new EtlPipelineConfig { Name = "Pipeline1" }, ["Pipeline2"] = new EtlPipelineConfig { Name = "Pipeline2" } }; var sut = CreateViewModel(); // Act sut.LoadConfigForTesting(config, pipelines); // Assert sut.TreeNodes[1].Name.ShouldBe("Pipelines"); sut.TreeNodes[1].Children.Count.ShouldBe(2); } [Fact] public async Task OpenFolderCommand_UsesFilePicker_AndDerivesFolder() { // Arrange var expectedFilePath = "/path/to/folder/appsettings.json"; var expectedFolder = "/path/to/folder"; var config = new ConfigModel(); // Ensure auto-discovery doesn't load config _autoDiscoveryService.FindConfigFolderAsync().Returns((string?)null); _dialogService.ShowFilePickerAsync(Arg.Any()) .Returns(expectedFilePath); _configFileService.LoadAppSettingsAsync(Arg.Any(), Arg.Any()) .Returns(config); var sut = CreateViewModel(); // Wait for constructor async init to complete await Task.Delay(50); _configFileService.ClearReceivedCalls(); // Act sut.OpenFolderCommand.Execute(null); // Give async command time to complete await Task.Delay(100); // Assert await _dialogService.Received(1).ShowFilePickerAsync("Select Configuration File"); await _configFileService.Received(1).LoadAppSettingsAsync( Arg.Is(s => s.Contains(expectedFolder)), Arg.Any()); sut.ConfigFolderPath.ShouldBe(expectedFolder); } [Fact] public async Task OpenFolderCommand_WhenCancelled_DoesNotLoadConfig() { // Arrange // Ensure auto-discovery doesn't load config _autoDiscoveryService.FindConfigFolderAsync().Returns((string?)null); _dialogService.ShowFilePickerAsync(Arg.Any()) .Returns((string?)null); var sut = CreateViewModel(); var originalPath = sut.ConfigFolderPath; // Wait for constructor async init to complete await Task.Delay(50); _configFileService.ClearReceivedCalls(); // Act sut.OpenFolderCommand.Execute(null); // Give async command time to complete await Task.Delay(100); // Assert await _dialogService.Received(1).ShowFilePickerAsync(Arg.Any()); await _configFileService.DidNotReceive().LoadAppSettingsAsync(Arg.Any(), Arg.Any()); sut.ConfigFolderPath.ShouldBe(originalPath); } [Fact] public async Task SaveCommand_SavesAppSettings() { // Arrange var testFolderPath = "/test/config"; var config = new ConfigModel(); _autoDiscoveryService.FindConfigFolderAsync().Returns((string?)null); var sut = CreateViewModel(); await Task.Delay(50); sut.LoadConfigForTesting(config, null); // Simulate setting the config folder path and marking as changed var configFolderProperty = typeof(MainWindowViewModel).GetProperty("ConfigFolderPath"); configFolderProperty!.SetValue(sut, testFolderPath); sut.HasUnsavedChanges = true; // Act sut.SaveCommand.Execute(null); await Task.Delay(100); // Assert - File.Exists is a static call so backup may not be called, but SaveAppSettings should be await _configFileService.Received().SaveAppSettingsAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task SaveCommand_ResetsHasUnsavedChanges() { // Arrange var testFolderPath = Path.GetTempPath(); // Use real temp path so Directory.CreateDirectory works var config = new ConfigModel { Pipelines = new PipelinesSection { ConfigDirectory = "Pipelines" } }; _autoDiscoveryService.FindConfigFolderAsync().Returns((string?)null); var sut = CreateViewModel(); await Task.Delay(50); sut.LoadConfigForTesting(config, null); var configFolderProperty = typeof(MainWindowViewModel).GetProperty("ConfigFolderPath"); configFolderProperty!.SetValue(sut, testFolderPath); sut.HasUnsavedChanges = true; // Act sut.SaveCommand.Execute(null); await Task.Delay(150); // Assert - After successful save, HasUnsavedChanges should be false sut.HasUnsavedChanges.ShouldBeFalse(); } // Note: ValidateCommand tests are skipped because the Validate() method creates // Avalonia SolidColorBrush objects which require UI thread access. These tests // would need to use [AvaloniaFact] and run in the UI context, but the command // validation logic is covered by the ValidationServiceTests. [Fact] public async Task AddSecretCommand_WhenStoreNotOpen_DoesNotShowDialog() { // Arrange _autoDiscoveryService.FindConfigFolderAsync().Returns((string?)null); _secureStoreManager.IsStoreOpen.Returns(false); var sut = CreateViewModel(); await Task.Delay(50); // Act sut.AddSecretCommand.Execute(null); await Task.Delay(100); // Assert - Command should not execute when store is not open await _dialogService.DidNotReceive().ShowMessageAsync( Arg.Is(s => s == "Add Secret"), Arg.Any()); } [Fact] public async Task AddSecretCommand_WhenStoreOpen_ShowsDialog() { // Arrange _autoDiscoveryService.FindConfigFolderAsync().Returns((string?)null); _secureStoreManager.IsStoreOpen.Returns(true); var sut = CreateViewModel(); await Task.Delay(50); // Act sut.AddSecretCommand.Execute(null); await Task.Delay(100); // Assert await _dialogService.Received().ShowMessageAsync( Arg.Is(s => s == "Add Secret"), Arg.Any()); } [Fact] public async Task DeleteSecretCommand_WhenConfirmed_DeletesSecret() { // Arrange var config = new ConfigModel { SecureStore = new SecureStoreSection { StorePath = "test.store", KeyFilePath = "test.key" } }; _autoDiscoveryService.FindConfigFolderAsync().Returns((string?)null); _secureStoreManager.IsStoreOpen.Returns(true); _secureStoreManager.GetKeys().Returns(new List { "TestSecret" }); _dialogService.ShowConfirmationAsync(Arg.Any(), Arg.Any()) .Returns(true); var sut = CreateViewModel(); await Task.Delay(50); // Set ConfigFolderPath first - required for SecureStore node to be built var configFolderProperty = typeof(MainWindowViewModel).GetProperty("ConfigFolderPath"); configFolderProperty!.SetValue(sut, "/test/config"); sut.LoadConfigForTesting(config, null); // Select the secret node var secureStoreNode = sut.TreeNodes.FirstOrDefault(n => n.NodeType == TreeNodeType.SecureStore); secureStoreNode.ShouldNotBeNull(); var secretNode = secureStoreNode.Children.FirstOrDefault(); secretNode.ShouldNotBeNull(); sut.SelectedNode = secretNode; // Act sut.DeleteSecretCommand.Execute(null); await Task.Delay(100); // Assert _secureStoreManager.Received().RemoveSecret("TestSecret"); } [Fact] public async Task DeleteSecretCommand_WhenCancelled_DoesNotDelete() { // Arrange var config = new ConfigModel { SecureStore = new SecureStoreSection { StorePath = "test.store", KeyFilePath = "test.key" } }; _autoDiscoveryService.FindConfigFolderAsync().Returns((string?)null); _secureStoreManager.IsStoreOpen.Returns(true); _secureStoreManager.GetKeys().Returns(new List { "TestSecret" }); _dialogService.ShowConfirmationAsync(Arg.Any(), Arg.Any()) .Returns(false); var sut = CreateViewModel(); await Task.Delay(50); // Set ConfigFolderPath first - required for SecureStore node to be built var configFolderProperty = typeof(MainWindowViewModel).GetProperty("ConfigFolderPath"); configFolderProperty!.SetValue(sut, "/test/config"); sut.LoadConfigForTesting(config, null); // Select the secret node var secureStoreNode = sut.TreeNodes.FirstOrDefault(n => n.NodeType == TreeNodeType.SecureStore); secureStoreNode.ShouldNotBeNull(); var secretNode = secureStoreNode.Children.FirstOrDefault(); secretNode.ShouldNotBeNull(); sut.SelectedNode = secretNode; // Act sut.DeleteSecretCommand.Execute(null); await Task.Delay(100); // Assert _secureStoreManager.DidNotReceive().RemoveSecret(Arg.Any()); } [Fact] public async Task AddPipelineCommand_ShowsDialog_AndAddsPipeline() { // Arrange var config = new ConfigModel(); var pipelines = new Dictionary(); _autoDiscoveryService.FindConfigFolderAsync().Returns((string?)null); _dialogService.ShowInputDialogAsync(Arg.Any(), Arg.Any()) .Returns("NewPipeline"); var sut = CreateViewModel(); await Task.Delay(50); sut.LoadConfigForTesting(config, pipelines); // Act sut.AddPipelineCommand.Execute(null); await Task.Delay(100); // Assert await _dialogService.Received().ShowInputDialogAsync("New Pipeline", "Enter pipeline name:"); var pipelinesFolder = sut.TreeNodes.FirstOrDefault(n => n.Name == "Pipelines"); pipelinesFolder.ShouldNotBeNull(); pipelinesFolder.Children.Any(c => c.Name == "NewPipeline").ShouldBeTrue(); } [Fact] public async Task AddPipelineCommand_WithDuplicateName_ShowsError() { // Arrange var config = new ConfigModel(); var pipelines = new Dictionary { ["ExistingPipeline"] = new EtlPipelineConfig { Name = "ExistingPipeline", Source = new SourceElement { Connection = "jde", Query = "SELECT 1" }, Destination = new DestinationElement { Table = "TestTable" } } }; _autoDiscoveryService.FindConfigFolderAsync().Returns((string?)null); _dialogService.ShowInputDialogAsync(Arg.Any(), Arg.Any()) .Returns("ExistingPipeline"); var sut = CreateViewModel(); await Task.Delay(50); sut.LoadConfigForTesting(config, pipelines); // Act sut.AddPipelineCommand.Execute(null); await Task.Delay(100); // Assert await _dialogService.Received().ShowMessageAsync( "Error", "Pipeline 'ExistingPipeline' already exists."); } [Fact] public async Task DeletePipelineCommand_WhenConfirmed_RemovesPipelineFromTree() { // Arrange var config = new ConfigModel { Pipelines = new PipelinesSection { ConfigDirectory = "Pipelines" } }; var pipelines = new Dictionary { ["TestPipeline"] = new EtlPipelineConfig { Name = "TestPipeline", Source = new SourceElement { Connection = "jde", Query = "SELECT 1" }, Destination = new DestinationElement { Table = "TestTable" } } }; _autoDiscoveryService.FindConfigFolderAsync().Returns((string?)null); _dialogService.ShowConfirmationAsync(Arg.Any(), Arg.Any()) .Returns(true); var sut = CreateViewModel(); await Task.Delay(50); sut.LoadConfigForTesting(config, pipelines); var configFolderProperty = typeof(MainWindowViewModel).GetProperty("ConfigFolderPath"); configFolderProperty!.SetValue(sut, "/test/config"); // Select the pipeline node var pipelineNode = sut.TreeNodes .SelectMany(n => n.Children) .First(n => n.SectionKey == "TestPipeline"); sut.SelectedNode = pipelineNode; // Act sut.DeletePipelineCommand.Execute(null); await Task.Delay(100); // Assert await _dialogService.Received().ShowConfirmationAsync( "Delete Pipeline", "Are you sure you want to delete pipeline 'TestPipeline'?"); // Pipeline should be removed from tree var pipelinesFolder = sut.TreeNodes.First(n => n.Name == "Pipelines"); pipelinesFolder.Children.Any(c => c.SectionKey == "TestPipeline").ShouldBeFalse(); } [Fact] public async Task DeletePipelineCommand_WhenCancelled_DoesNotDelete() { // Arrange var config = new ConfigModel(); var pipelines = new Dictionary { ["TestPipeline"] = new EtlPipelineConfig { Name = "TestPipeline", Source = new SourceElement { Connection = "jde", Query = "SELECT 1" }, Destination = new DestinationElement { Table = "TestTable" } } }; _autoDiscoveryService.FindConfigFolderAsync().Returns((string?)null); _dialogService.ShowConfirmationAsync(Arg.Any(), Arg.Any()) .Returns(false); var sut = CreateViewModel(); await Task.Delay(50); sut.LoadConfigForTesting(config, pipelines); // Select the pipeline node var pipelineNode = sut.TreeNodes .SelectMany(n => n.Children) .First(n => n.SectionKey == "TestPipeline"); sut.SelectedNode = pipelineNode; var originalChildCount = sut.TreeNodes .First(n => n.Name == "Pipelines").Children.Count; // Act sut.DeletePipelineCommand.Execute(null); await Task.Delay(100); // Assert await _configFileService.DidNotReceive().DeletePipelineFileAsync(Arg.Any()); sut.TreeNodes.First(n => n.Name == "Pipelines").Children.Count.ShouldBe(originalChildCount); } [Fact] public async Task ValidateRuntimeConfigCommand_CallsRuntimeValidationService() { // Arrange var testFolderPath = "/test/config"; var config = new ConfigModel(); _autoDiscoveryService.FindConfigFolderAsync().Returns((string?)null); _runtimeValidationService.ValidateRuntimeConfig(Arg.Any()) .Returns(new List()); var sut = CreateViewModel(); await Task.Delay(50); sut.LoadConfigForTesting(config, null); var configFolderProperty = typeof(MainWindowViewModel).GetProperty("ConfigFolderPath"); configFolderProperty!.SetValue(sut, testFolderPath); // Act sut.ValidateRuntimeConfigCommand.Execute(null); await Task.Delay(100); // Assert _runtimeValidationService.Received().ValidateRuntimeConfig(testFolderPath); await _dialogService.Received().ShowValidationResultsAsync( Arg.Any(), Arg.Any()); } [Fact] public async Task ValidateRuntimeConfigCommand_WithErrors_ShowsErrorsInDialog() { // Arrange var testFolderPath = "/test/config"; var config = new ConfigModel(); var runtimeResult = new RuntimeValidationResult { ValidatorName = "TestValidator" }; runtimeResult.Errors.Add("Test runtime error"); _autoDiscoveryService.FindConfigFolderAsync().Returns((string?)null); _runtimeValidationService.ValidateRuntimeConfig(Arg.Any()) .Returns(new List { runtimeResult }); var sut = CreateViewModel(); await Task.Delay(50); sut.LoadConfigForTesting(config, null); var configFolderProperty = typeof(MainWindowViewModel).GetProperty("ConfigFolderPath"); configFolderProperty!.SetValue(sut, testFolderPath); // Act sut.ValidateRuntimeConfigCommand.Execute(null); await Task.Delay(100); // Assert await _dialogService.Received().ShowValidationResultsAsync( Arg.Is(r => r.Errors.Count > 0), Arg.Any()); } private MainWindowViewModel CreateViewModel() { return new MainWindowViewModel( _fileSystem, _configFileService, _validationService, _backupService, _autoDiscoveryService, _dialogService, _secureStoreManager, _clipboardService, _runtimeValidationService, _connectionTestService, _logger); } }