Files
Joseph Doherty 6f3e12b3b4 refactor(configmanager): convert CLI to structured logging with Serilog
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
2026-01-28 15:53:08 -05:00

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