6f3e12b3b4
Replace Console.WriteLine calls with ILogger usage across all CLI commands. Serilog is configured via DI with clean message-only output suitable for CLI tooling. Log levels map to --quiet (Warning), default (Information), and --verbose (Debug) flags. - Add Serilog packages and configure in Program.cs - Convert all 7 command files to use ILoggerFactory from DI - Add BeginScope with context properties (Command, ConfigPath, etc.) - Create logging_style.md documenting patterns and best practices - Update tests with TestLoggingHelper for Serilog test configuration
507 lines
20 KiB
C#
507 lines
20 KiB
C#
using System.CommandLine;
|
|
using JdeScoping.ConfigManager.Cli.Commands;
|
|
using JdeScoping.ConfigManager.Core.Models;
|
|
using JdeScoping.ConfigManager.Core.Services;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
namespace JdeScoping.ConfigManager.Cli.Tests.Commands;
|
|
|
|
[Collection("Console Tests")]
|
|
public class ConfigCommandsTests
|
|
{
|
|
private readonly IServiceProvider _serviceProvider;
|
|
private readonly IConfigFileService _configFileService;
|
|
private readonly IAutoDiscoveryService _autoDiscoveryService;
|
|
private readonly IBackupService _backupService;
|
|
private readonly Option<string?> _configPathOption;
|
|
private readonly Option<bool> _verboseOption;
|
|
private readonly Option<bool> _quietOption;
|
|
|
|
public ConfigCommandsTests()
|
|
{
|
|
_configFileService = Substitute.For<IConfigFileService>();
|
|
_autoDiscoveryService = Substitute.For<IAutoDiscoveryService>();
|
|
_backupService = Substitute.For<IBackupService>();
|
|
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton(_configFileService);
|
|
services.AddSingleton(_autoDiscoveryService);
|
|
services.AddSingleton(_backupService);
|
|
services.AddTestLogging();
|
|
_serviceProvider = services.BuildServiceProvider();
|
|
|
|
_configPathOption = new Option<string?>(["--config-path", "-c"]);
|
|
_verboseOption = new Option<bool>(["--verbose", "-v"]);
|
|
_quietOption = new Option<bool>(["--quiet", "-q"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateShowCommand_ReturnsCommandWithSubcommands()
|
|
{
|
|
// Act
|
|
var command = ConfigCommands.CreateShowCommand(
|
|
_serviceProvider, _configPathOption, _verboseOption, _quietOption);
|
|
|
|
// Assert
|
|
command.ShouldNotBeNull();
|
|
command.Name.ShouldBe("show");
|
|
command.Subcommands.ShouldContain(c => c.Name == "datasync");
|
|
command.Subcommands.ShouldContain(c => c.Name == "dataaccess");
|
|
command.Subcommands.ShouldContain(c => c.Name == "auth");
|
|
command.Subcommands.ShouldContain(c => c.Name == "ldap");
|
|
command.Subcommands.ShouldContain(c => c.Name == "search");
|
|
command.Subcommands.ShouldContain(c => c.Name == "excelexport");
|
|
command.Subcommands.ShouldContain(c => c.Name == "securestore");
|
|
command.Subcommands.ShouldContain(c => c.Name == "connections");
|
|
command.Subcommands.ShouldContain(c => c.Name == "all");
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateShowCommand_HasJsonOption()
|
|
{
|
|
// Act
|
|
var command = ConfigCommands.CreateShowCommand(
|
|
_serviceProvider, _configPathOption, _verboseOption, _quietOption);
|
|
|
|
// Assert
|
|
command.Options.ShouldContain(o => o.Name == "json");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ShowDataSyncCommand_WithNoConfigFolder_ReturnsError()
|
|
{
|
|
// Arrange
|
|
_autoDiscoveryService.FindConfigFolderAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<string?>(null));
|
|
|
|
var command = ConfigCommands.CreateShowCommand(
|
|
_serviceProvider, _configPathOption, _verboseOption, _quietOption);
|
|
var rootCommand = new RootCommand { command };
|
|
rootCommand.AddGlobalOption(_configPathOption);
|
|
rootCommand.AddGlobalOption(_verboseOption);
|
|
rootCommand.AddGlobalOption(_quietOption);
|
|
|
|
// Act
|
|
var exitCode = await rootCommand.InvokeAsync(["show", "datasync"]);
|
|
|
|
// Assert - command completes without throwing
|
|
command.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void DataSyncSubcommand_Exists()
|
|
{
|
|
// Arrange
|
|
var command = ConfigCommands.CreateShowCommand(
|
|
_serviceProvider, _configPathOption, _verboseOption, _quietOption);
|
|
|
|
// Act
|
|
var datasyncSubcommand = command.Subcommands.FirstOrDefault(c => c.Name == "datasync");
|
|
|
|
// Assert
|
|
datasyncSubcommand.ShouldNotBeNull();
|
|
datasyncSubcommand.Description.ShouldBe("Show DataSync configuration");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ShowConnectionsCommand_MasksNoPasswords()
|
|
{
|
|
// Arrange
|
|
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
|
Directory.CreateDirectory(tempDir);
|
|
var appSettingsPath = Path.Combine(tempDir, "appsettings.json");
|
|
File.WriteAllText(appSettingsPath, "{}");
|
|
|
|
try
|
|
{
|
|
_autoDiscoveryService.FindConfigFolderAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<string?>(tempDir));
|
|
|
|
var config = new ConfigModel
|
|
{
|
|
ConnectionStrings = new ConnectionStringsSection
|
|
{
|
|
Entries =
|
|
[
|
|
new ConnectionStringEntry
|
|
{
|
|
Name = "TestConnection",
|
|
Provider = ConnectionProvider.SqlServer,
|
|
Server = "localhost",
|
|
Password = "secret123"
|
|
}
|
|
]
|
|
}
|
|
};
|
|
|
|
_configFileService.LoadAppSettingsAsync(appSettingsPath, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult(config));
|
|
|
|
var command = ConfigCommands.CreateShowCommand(
|
|
_serviceProvider, _configPathOption, _verboseOption, _quietOption);
|
|
var rootCommand = new RootCommand { command };
|
|
rootCommand.AddGlobalOption(_configPathOption);
|
|
rootCommand.AddGlobalOption(_verboseOption);
|
|
rootCommand.AddGlobalOption(_quietOption);
|
|
|
|
// Capture console output
|
|
var originalOut = Console.Out;
|
|
using var writer = new StringWriter();
|
|
Console.SetOut(writer);
|
|
|
|
try
|
|
{
|
|
// Act
|
|
await rootCommand.InvokeAsync(["show", "connections"]);
|
|
|
|
// Assert
|
|
var output = writer.ToString();
|
|
output.ShouldContain("TestConnection");
|
|
output.ShouldNotContain("secret123"); // Password should not be visible
|
|
}
|
|
finally
|
|
{
|
|
Console.SetOut(originalOut);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(tempDir, true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void AllSubcommand_HasJsonOption()
|
|
{
|
|
// Arrange
|
|
var command = ConfigCommands.CreateShowCommand(
|
|
_serviceProvider, _configPathOption, _verboseOption, _quietOption);
|
|
|
|
// Act
|
|
var allSubcommand = command.Subcommands.FirstOrDefault(c => c.Name == "all");
|
|
|
|
// Assert
|
|
allSubcommand.ShouldNotBeNull();
|
|
// The json option is on the parent show command
|
|
command.Options.ShouldContain(o => o.Name == "json");
|
|
}
|
|
|
|
// ===== Config Set Command Tests =====
|
|
|
|
[Fact]
|
|
public void CreateSetCommand_ReturnsCommandWithSubcommands()
|
|
{
|
|
// Act
|
|
var command = ConfigCommands.CreateSetCommand(
|
|
_serviceProvider, _configPathOption, _verboseOption, _quietOption);
|
|
|
|
// Assert
|
|
command.ShouldNotBeNull();
|
|
command.Name.ShouldBe("set");
|
|
command.Subcommands.ShouldContain(c => c.Name == "datasync");
|
|
command.Subcommands.ShouldContain(c => c.Name == "dataaccess");
|
|
command.Subcommands.ShouldContain(c => c.Name == "auth");
|
|
command.Subcommands.ShouldContain(c => c.Name == "ldap");
|
|
command.Subcommands.ShouldContain(c => c.Name == "search");
|
|
command.Subcommands.ShouldContain(c => c.Name == "excelexport");
|
|
}
|
|
|
|
[Fact]
|
|
public void SetDataSyncCommand_HasExpectedOptions()
|
|
{
|
|
// Arrange
|
|
var command = ConfigCommands.CreateSetCommand(
|
|
_serviceProvider, _configPathOption, _verboseOption, _quietOption);
|
|
|
|
// Act
|
|
var datasyncSubcommand = command.Subcommands.FirstOrDefault(c => c.Name == "datasync");
|
|
|
|
// Assert
|
|
datasyncSubcommand.ShouldNotBeNull();
|
|
datasyncSubcommand.Options.ShouldContain(o => o.Name == "enabled");
|
|
datasyncSubcommand.Options.ShouldContain(o => o.Name == "check-interval");
|
|
datasyncSubcommand.Options.ShouldContain(o => o.Name == "max-parallelism");
|
|
datasyncSubcommand.Options.ShouldContain(o => o.Name == "batch-size");
|
|
datasyncSubcommand.Options.ShouldContain(o => o.Name == "bulk-copy-batch-size");
|
|
datasyncSubcommand.Options.ShouldContain(o => o.Name == "lookback-multiplier");
|
|
datasyncSubcommand.Options.ShouldContain(o => o.Name == "purge-retention-days");
|
|
datasyncSubcommand.Options.ShouldContain(o => o.Name == "sync-timeout");
|
|
}
|
|
|
|
[Fact]
|
|
public void SetDataAccessCommand_HasExpectedOptions()
|
|
{
|
|
// Arrange
|
|
var command = ConfigCommands.CreateSetCommand(
|
|
_serviceProvider, _configPathOption, _verboseOption, _quietOption);
|
|
|
|
// Act
|
|
var dataAccessSubcommand = command.Subcommands.FirstOrDefault(c => c.Name == "dataaccess");
|
|
|
|
// Assert
|
|
dataAccessSubcommand.ShouldNotBeNull();
|
|
dataAccessSubcommand.Options.ShouldContain(o => o.Name == "default-timeout");
|
|
dataAccessSubcommand.Options.ShouldContain(o => o.Name == "lot-usage-timeout");
|
|
dataAccessSubcommand.Options.ShouldContain(o => o.Name == "mis-data-timeout");
|
|
dataAccessSubcommand.Options.ShouldContain(o => o.Name == "detailed-logging");
|
|
dataAccessSubcommand.Options.ShouldContain(o => o.Name == "production-schema");
|
|
dataAccessSubcommand.Options.ShouldContain(o => o.Name == "archive-schema");
|
|
dataAccessSubcommand.Options.ShouldContain(o => o.Name == "stage-schema");
|
|
}
|
|
|
|
[Fact]
|
|
public void SetAuthCommand_HasExpectedOptions()
|
|
{
|
|
// Arrange
|
|
var command = ConfigCommands.CreateSetCommand(
|
|
_serviceProvider, _configPathOption, _verboseOption, _quietOption);
|
|
|
|
// Act
|
|
var authSubcommand = command.Subcommands.FirstOrDefault(c => c.Name == "auth");
|
|
|
|
// Assert
|
|
authSubcommand.ShouldNotBeNull();
|
|
authSubcommand.Options.ShouldContain(o => o.Name == "cookie-name");
|
|
authSubcommand.Options.ShouldContain(o => o.Name == "cookie-expiration");
|
|
}
|
|
|
|
[Fact]
|
|
public void SetLdapCommand_HasExpectedOptions()
|
|
{
|
|
// Arrange
|
|
var command = ConfigCommands.CreateSetCommand(
|
|
_serviceProvider, _configPathOption, _verboseOption, _quietOption);
|
|
|
|
// Act
|
|
var ldapSubcommand = command.Subcommands.FirstOrDefault(c => c.Name == "ldap");
|
|
|
|
// Assert
|
|
ldapSubcommand.ShouldNotBeNull();
|
|
ldapSubcommand.Options.ShouldContain(o => o.Name == "server-urls");
|
|
ldapSubcommand.Options.ShouldContain(o => o.Name == "group-dn");
|
|
ldapSubcommand.Options.ShouldContain(o => o.Name == "search-base");
|
|
ldapSubcommand.Options.ShouldContain(o => o.Name == "connection-timeout");
|
|
ldapSubcommand.Options.ShouldContain(o => o.Name == "use-fake-auth");
|
|
ldapSubcommand.Options.ShouldContain(o => o.Name == "admin-bypass-users");
|
|
}
|
|
|
|
[Fact]
|
|
public void SetSearchCommand_HasExpectedOptions()
|
|
{
|
|
// Arrange
|
|
var command = ConfigCommands.CreateSetCommand(
|
|
_serviceProvider, _configPathOption, _verboseOption, _quietOption);
|
|
|
|
// Act
|
|
var searchSubcommand = command.Subcommands.FirstOrDefault(c => c.Name == "search");
|
|
|
|
// Assert
|
|
searchSubcommand.ShouldNotBeNull();
|
|
searchSubcommand.Options.ShouldContain(o => o.Name == "max-result-rows");
|
|
searchSubcommand.Options.ShouldContain(o => o.Name == "timeout");
|
|
searchSubcommand.Options.ShouldContain(o => o.Name == "max-concurrent");
|
|
}
|
|
|
|
[Fact]
|
|
public void SetExcelExportCommand_HasExpectedOptions()
|
|
{
|
|
// Arrange
|
|
var command = ConfigCommands.CreateSetCommand(
|
|
_serviceProvider, _configPathOption, _verboseOption, _quietOption);
|
|
|
|
// Act
|
|
var excelExportSubcommand = command.Subcommands.FirstOrDefault(c => c.Name == "excelexport");
|
|
|
|
// Assert
|
|
excelExportSubcommand.ShouldNotBeNull();
|
|
excelExportSubcommand.Options.ShouldContain(o => o.Name == "max-rows-per-sheet");
|
|
excelExportSubcommand.Options.ShouldContain(o => o.Name == "date-format");
|
|
excelExportSubcommand.Options.ShouldContain(o => o.Name == "timezone-id");
|
|
excelExportSubcommand.Options.ShouldContain(o => o.Name == "debug-write-to-file");
|
|
excelExportSubcommand.Options.ShouldContain(o => o.Name == "debug-output-dir");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetDataSyncCommand_WithNoConfigFolder_ReturnsError()
|
|
{
|
|
// Arrange
|
|
_autoDiscoveryService.FindConfigFolderAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<string?>(null));
|
|
|
|
var command = ConfigCommands.CreateSetCommand(
|
|
_serviceProvider, _configPathOption, _verboseOption, _quietOption);
|
|
var rootCommand = new RootCommand { command };
|
|
rootCommand.AddGlobalOption(_configPathOption);
|
|
rootCommand.AddGlobalOption(_verboseOption);
|
|
rootCommand.AddGlobalOption(_quietOption);
|
|
|
|
// Act
|
|
var exitCode = await rootCommand.InvokeAsync(["set", "datasync", "--enabled", "true"]);
|
|
|
|
// Assert - command completes without throwing
|
|
command.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetDataSyncCommand_WithNoChanges_ReturnsError()
|
|
{
|
|
// Arrange
|
|
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
|
Directory.CreateDirectory(tempDir);
|
|
var appSettingsPath = Path.Combine(tempDir, "appsettings.json");
|
|
File.WriteAllText(appSettingsPath, "{}");
|
|
|
|
try
|
|
{
|
|
_autoDiscoveryService.FindConfigFolderAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<string?>(tempDir));
|
|
|
|
var config = new ConfigModel();
|
|
_configFileService.LoadAppSettingsAsync(appSettingsPath, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult(config));
|
|
|
|
var command = ConfigCommands.CreateSetCommand(
|
|
_serviceProvider, _configPathOption, _verboseOption, _quietOption);
|
|
var rootCommand = new RootCommand { command };
|
|
rootCommand.AddGlobalOption(_configPathOption);
|
|
rootCommand.AddGlobalOption(_verboseOption);
|
|
rootCommand.AddGlobalOption(_quietOption);
|
|
|
|
// Capture console output (Serilog writes all output to stdout)
|
|
var originalOut = Console.Out;
|
|
using var writer = new StringWriter();
|
|
Console.SetOut(writer);
|
|
|
|
try
|
|
{
|
|
// Act - run without any options
|
|
await rootCommand.InvokeAsync(["set", "datasync"]);
|
|
|
|
// Assert
|
|
var output = writer.ToString();
|
|
output.ShouldContain("No changes specified");
|
|
}
|
|
finally
|
|
{
|
|
Console.SetOut(originalOut);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(tempDir, true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetDataSyncCommand_WithValidChange_UpdatesAndSaves()
|
|
{
|
|
// Arrange
|
|
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
|
Directory.CreateDirectory(tempDir);
|
|
var appSettingsPath = Path.Combine(tempDir, "appsettings.json");
|
|
File.WriteAllText(appSettingsPath, "{}");
|
|
|
|
try
|
|
{
|
|
_autoDiscoveryService.FindConfigFolderAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<string?>(tempDir));
|
|
|
|
var config = new ConfigModel();
|
|
_configFileService.LoadAppSettingsAsync(appSettingsPath, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult(config));
|
|
|
|
_backupService.CreateBackupAsync(appSettingsPath, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult(Path.Combine(tempDir, "backup.json")));
|
|
|
|
var command = ConfigCommands.CreateSetCommand(
|
|
_serviceProvider, _configPathOption, _verboseOption, _quietOption);
|
|
var rootCommand = new RootCommand { command };
|
|
rootCommand.AddGlobalOption(_configPathOption);
|
|
rootCommand.AddGlobalOption(_verboseOption);
|
|
rootCommand.AddGlobalOption(_quietOption);
|
|
|
|
// Capture console output
|
|
var originalOut = Console.Out;
|
|
using var writer = new StringWriter();
|
|
Console.SetOut(writer);
|
|
|
|
try
|
|
{
|
|
// Act
|
|
await rootCommand.InvokeAsync(["set", "datasync", "--enabled", "false"]);
|
|
|
|
// Assert
|
|
var output = writer.ToString();
|
|
output.ShouldContain("updated successfully");
|
|
await _configFileService.Received(1).SaveAppSettingsAsync(appSettingsPath, Arg.Any<ConfigModel>(), Arg.Any<CancellationToken>());
|
|
await _backupService.Received(1).CreateBackupAsync(appSettingsPath, Arg.Any<CancellationToken>());
|
|
}
|
|
finally
|
|
{
|
|
Console.SetOut(originalOut);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(tempDir, true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetSearchCommand_WithValidChange_UpdatesAndSaves()
|
|
{
|
|
// Arrange
|
|
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
|
Directory.CreateDirectory(tempDir);
|
|
var appSettingsPath = Path.Combine(tempDir, "appsettings.json");
|
|
File.WriteAllText(appSettingsPath, "{}");
|
|
|
|
try
|
|
{
|
|
_autoDiscoveryService.FindConfigFolderAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<string?>(tempDir));
|
|
|
|
var config = new ConfigModel();
|
|
_configFileService.LoadAppSettingsAsync(appSettingsPath, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult(config));
|
|
|
|
_backupService.CreateBackupAsync(appSettingsPath, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult(Path.Combine(tempDir, "backup.json")));
|
|
|
|
var command = ConfigCommands.CreateSetCommand(
|
|
_serviceProvider, _configPathOption, _verboseOption, _quietOption);
|
|
var rootCommand = new RootCommand { command };
|
|
rootCommand.AddGlobalOption(_configPathOption);
|
|
rootCommand.AddGlobalOption(_verboseOption);
|
|
rootCommand.AddGlobalOption(_quietOption);
|
|
|
|
// Capture console output
|
|
var originalOut = Console.Out;
|
|
using var writer = new StringWriter();
|
|
Console.SetOut(writer);
|
|
|
|
try
|
|
{
|
|
// Act
|
|
await rootCommand.InvokeAsync(["set", "search", "--max-result-rows", "50000"]);
|
|
|
|
// Assert
|
|
var output = writer.ToString();
|
|
output.ShouldContain("updated successfully");
|
|
await _configFileService.Received(1).SaveAppSettingsAsync(
|
|
appSettingsPath,
|
|
Arg.Is<ConfigModel>(c => c.Search.MaxResultRows == 50000),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
finally
|
|
{
|
|
Console.SetOut(originalOut);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(tempDir, true);
|
|
}
|
|
}
|
|
}
|