test(configmanager): expand unit test coverage to 451 tests

Add comprehensive tests for services (ConnectionTestService, RuntimeConfigValidation),
ViewModels (PipelineEditor, dialogs, transformers), and Avalonia headless UI tests
for views and forms.
This commit is contained in:
Joseph Doherty
2026-01-27 07:24:55 -05:00
parent 227a749cdf
commit 937eb66ac8
14 changed files with 4053 additions and 62 deletions
@@ -63,4 +63,175 @@ public class BackupServiceTests
// Assert - should delete 5 oldest backups
await _fileSystem.Received(5).DeleteFileAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetBackupsAsync_ReturnsBackupsSortedByTimestampDescending()
{
// Arrange
var filePath = "/config/appsettings.json";
_fileSystem.GetDirectoryName(filePath).Returns("/config");
_fileSystem.GetFileNameWithoutExtension(filePath).Returns("appsettings");
// Backups in random order
var backups = new[]
{
"/config/appsettings.2026-01-15_120000.bak",
"/config/appsettings.2026-01-10_120000.bak",
"/config/appsettings.2026-01-20_120000.bak"
};
_fileSystem.GetFilesAsync("/config", "appsettings.*.bak", Arg.Any<CancellationToken>())
.Returns(Task.FromResult(backups));
// Mock GetFileNameWithoutExtension for each backup file
foreach (var backup in backups)
{
var fileName = backup.Split('/').Last().Replace(".bak", "");
_fileSystem.GetFileNameWithoutExtension(backup).Returns(fileName);
}
// Act
var result = await _sut.GetBackupsAsync(filePath);
// Assert
result.ShouldNotBeNull();
result.Count.ShouldBe(3);
// Should be sorted descending by timestamp (newest first)
result[0].Path.ShouldContain("2026-01-20");
result[1].Path.ShouldContain("2026-01-15");
result[2].Path.ShouldContain("2026-01-10");
}
[Fact]
public async Task GetBackupsAsync_WithNoBackups_ReturnsEmptyList()
{
// Arrange
var filePath = "/config/appsettings.json";
_fileSystem.GetDirectoryName(filePath).Returns("/config");
_fileSystem.GetFileNameWithoutExtension(filePath).Returns("appsettings");
_fileSystem.GetFilesAsync("/config", "appsettings.*.bak", Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Array.Empty<string>()));
// Act
var result = await _sut.GetBackupsAsync(filePath);
// Assert
result.ShouldNotBeNull();
result.ShouldBeEmpty();
}
[Fact]
public async Task RestoreBackupAsync_CopiesBackupToTarget()
{
// Arrange
var backupPath = "/config/appsettings.2026-01-15_120000.bak";
var targetPath = "/config/appsettings.json";
// Act
await _sut.RestoreBackupAsync(backupPath, targetPath);
// Assert
await _fileSystem.Received(1).CopyFileAsync(backupPath, targetPath, Arg.Any<CancellationToken>());
}
[Fact]
public async Task CleanupOldBackupsAsync_WithFewerThanKeepCount_DeletesNone()
{
// Arrange
var filePath = "/config/appsettings.json";
_fileSystem.GetDirectoryName(filePath).Returns("/config");
_fileSystem.GetFileNameWithoutExtension(filePath).Returns("appsettings");
// Only 3 backups, but keepCount is 10
var backups = new[]
{
"/config/appsettings.2026-01-15_120000.bak",
"/config/appsettings.2026-01-16_120000.bak",
"/config/appsettings.2026-01-17_120000.bak"
};
_fileSystem.GetFilesAsync("/config", "appsettings.*.bak", Arg.Any<CancellationToken>())
.Returns(Task.FromResult(backups));
foreach (var backup in backups)
{
var fileName = backup.Split('/').Last().Replace(".bak", "");
_fileSystem.GetFileNameWithoutExtension(backup).Returns(fileName);
}
// Act
await _sut.CleanupOldBackupsAsync(filePath, keepCount: 10);
// Assert - no files should be deleted
await _fileSystem.DidNotReceive().DeleteFileAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task CreateBackupAsync_WithNonExistentFile_ThrowsFileNotFoundException()
{
// Arrange
var sourcePath = "/config/nonexistent.json";
_fileSystem.FileExists(sourcePath).Returns(false);
// Act & Assert
await Should.ThrowAsync<FileNotFoundException>(
() => _sut.CreateBackupAsync(sourcePath));
}
[Fact]
public async Task GetBackupsAsync_SkipsFilesWithInvalidTimestampFormat()
{
// Arrange
var filePath = "/config/appsettings.json";
_fileSystem.GetDirectoryName(filePath).Returns("/config");
_fileSystem.GetFileNameWithoutExtension(filePath).Returns("appsettings");
// Mix of valid and invalid backup filenames
var backups = new[]
{
"/config/appsettings.2026-01-15_120000.bak", // Valid
"/config/appsettings.invalid-format.bak", // Invalid
"/config/appsettings.2026-01-16_120000.bak" // Valid
};
_fileSystem.GetFilesAsync("/config", "appsettings.*.bak", Arg.Any<CancellationToken>())
.Returns(Task.FromResult(backups));
_fileSystem.GetFileNameWithoutExtension(backups[0]).Returns("appsettings.2026-01-15_120000");
_fileSystem.GetFileNameWithoutExtension(backups[1]).Returns("appsettings.invalid-format");
_fileSystem.GetFileNameWithoutExtension(backups[2]).Returns("appsettings.2026-01-16_120000");
// Act
var result = await _sut.GetBackupsAsync(filePath);
// Assert
result.ShouldNotBeNull();
result.Count.ShouldBe(2); // Only valid backups
}
[Fact]
public async Task CleanupOldBackupsAsync_WithExactKeepCount_DeletesNone()
{
// Arrange
var filePath = "/config/appsettings.json";
_fileSystem.GetDirectoryName(filePath).Returns("/config");
_fileSystem.GetFileNameWithoutExtension(filePath).Returns("appsettings");
// Exactly 5 backups with keepCount of 5
var backups = Enumerable.Range(1, 5)
.Select(i => $"/config/appsettings.2026-01-{i:D2}_120000.bak")
.ToArray();
_fileSystem.GetFilesAsync("/config", "appsettings.*.bak", Arg.Any<CancellationToken>())
.Returns(Task.FromResult(backups));
foreach (var backup in backups)
{
var fileName = backup.Split('/').Last().Replace(".bak", "");
_fileSystem.GetFileNameWithoutExtension(backup).Returns(fileName);
}
// Act
await _sut.CleanupOldBackupsAsync(filePath, keepCount: 5);
// Assert - no files should be deleted
await _fileSystem.DidNotReceive().DeleteFileAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
}
@@ -1,5 +1,6 @@
using JdeScoping.ConfigManager.Models;
using JdeScoping.ConfigManager.Services;
using JdeScoping.DataSync.Configuration;
namespace JdeScoping.ConfigManager.Tests.Services;
@@ -51,4 +52,221 @@ public class ConfigFileServiceTests
() => _sut.LoadAppSettingsAsync("/config/appsettings.json"));
ex.Message.ShouldContain("parse");
}
[Fact]
public async Task LoadPipelineAsync_WithValidJson_ReturnsPipelineConfig()
{
// Arrange
var json = """
{
"name": "TestPipeline",
"isEnabled": true,
"massSyncIntervalMinutes": 1440,
"source": {
"connectionStringName": "JDE",
"query": "SELECT * FROM test"
},
"destination": {
"connectionStringName": "LotFinder",
"tableName": "TestTable"
}
}
""";
_fileSystem.ReadAllTextAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(json));
// Act
var result = await _sut.LoadPipelineAsync("/config/pipeline.test.json");
// Assert
result.ShouldNotBeNull();
result.Name.ShouldBe("TestPipeline");
result.IsEnabled.ShouldBeTrue();
result.MassSyncIntervalMinutes.ShouldBe(1440);
}
[Fact]
public async Task LoadPipelineAsync_WithInvalidJson_ThrowsConfigLoadException()
{
// Arrange
var json = "{ invalid json }";
_fileSystem.ReadAllTextAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(json));
// Act & Assert
var ex = await Should.ThrowAsync<ConfigLoadException>(
() => _sut.LoadPipelineAsync("/config/pipeline.test.json"));
ex.Message.ShouldContain("parse");
}
[Fact]
public async Task SavePipelineAsync_SerializesAndWritesPipeline()
{
// Arrange
var path = "/config/pipeline.test.json";
var pipeline = new EtlPipelineConfig
{
Name = "TestPipeline",
IsEnabled = true,
MassSyncIntervalMinutes = 1440
};
string? writtenContent = null;
_fileSystem.WriteAllTextAsync(Arg.Any<string>(), Arg.Do<string>(c => writtenContent = c), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
// Act
await _sut.SavePipelineAsync(path, pipeline);
// Assert
await _fileSystem.Received(1).WriteAllTextAsync(path, Arg.Any<string>(), Arg.Any<CancellationToken>());
writtenContent.ShouldNotBeNull();
writtenContent.ShouldContain("TestPipeline");
}
[Fact]
public async Task LoadAllPipelinesAsync_WithMultiplePipelines_ReturnsAll()
{
// Arrange
var directory = "/config/pipelines";
var files = new[]
{
"/config/pipelines/pipeline.first.json",
"/config/pipelines/pipeline.second.json"
};
_fileSystem.DirectoryExists(directory).Returns(true);
_fileSystem.GetFilesAsync(directory, "pipeline.*.json", Arg.Any<CancellationToken>())
.Returns(Task.FromResult(files));
_fileSystem.ReadAllTextAsync(files[0], Arg.Any<CancellationToken>())
.Returns(Task.FromResult("""{"name": "First", "isEnabled": true}"""));
_fileSystem.ReadAllTextAsync(files[1], Arg.Any<CancellationToken>())
.Returns(Task.FromResult("""{"name": "Second", "isEnabled": false}"""));
_fileSystem.GetFileNameWithoutExtension(files[0]).Returns("pipeline.first");
_fileSystem.GetFileNameWithoutExtension(files[1]).Returns("pipeline.second");
// Act
var result = await _sut.LoadAllPipelinesAsync(directory);
// Assert
result.ShouldNotBeNull();
result.Count.ShouldBe(2);
result.ContainsKey("first").ShouldBeTrue();
result.ContainsKey("second").ShouldBeTrue();
result["first"].Name.ShouldBe("First");
result["second"].IsEnabled.ShouldBeFalse();
}
[Fact]
public async Task LoadAllPipelinesAsync_WithNonExistentDirectory_ReturnsEmpty()
{
// Arrange
var directory = "/config/nonexistent";
_fileSystem.DirectoryExists(directory).Returns(false);
// Act
var result = await _sut.LoadAllPipelinesAsync(directory);
// Assert
result.ShouldNotBeNull();
result.ShouldBeEmpty();
}
[Fact]
public async Task DeletePipelineFileAsync_CallsFileSystemDelete()
{
// Arrange
var path = "/config/pipeline.test.json";
// Act
await _sut.DeletePipelineFileAsync(path);
// Assert
await _fileSystem.Received(1).DeleteFileAsync(path, Arg.Any<CancellationToken>());
}
[Fact]
public async Task SaveAppSettingsAsync_SerializesAndWritesConfig()
{
// Arrange
var path = "/config/appsettings.json";
var config = new ConfigModel
{
DataSync = new DataSyncSection
{
Enabled = true,
MaxDegreeOfParallelism = 16
}
};
string? writtenContent = null;
_fileSystem.WriteAllTextAsync(Arg.Any<string>(), Arg.Do<string>(c => writtenContent = c), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
// Act
await _sut.SaveAppSettingsAsync(path, config);
// Assert
await _fileSystem.Received(1).WriteAllTextAsync(path, Arg.Any<string>(), Arg.Any<CancellationToken>());
writtenContent.ShouldNotBeNull();
writtenContent.ShouldContain("enabled");
writtenContent.ShouldContain("16");
}
[Fact]
public async Task LoadAllPipelinesAsync_SkipsInvalidPipelineFiles()
{
// Arrange
var directory = "/config/pipelines";
var files = new[]
{
"/config/pipelines/pipeline.valid.json",
"/config/pipelines/pipeline.invalid.json"
};
_fileSystem.DirectoryExists(directory).Returns(true);
_fileSystem.GetFilesAsync(directory, "pipeline.*.json", Arg.Any<CancellationToken>())
.Returns(Task.FromResult(files));
_fileSystem.ReadAllTextAsync(files[0], Arg.Any<CancellationToken>())
.Returns(Task.FromResult("""{"name": "Valid", "isEnabled": true}"""));
_fileSystem.ReadAllTextAsync(files[1], Arg.Any<CancellationToken>())
.Returns(Task.FromResult("{ invalid json }"));
_fileSystem.GetFileNameWithoutExtension(files[0]).Returns("pipeline.valid");
_fileSystem.GetFileNameWithoutExtension(files[1]).Returns("pipeline.invalid");
// Act
var result = await _sut.LoadAllPipelinesAsync(directory);
// Assert
result.ShouldNotBeNull();
result.Count.ShouldBe(1);
result.ContainsKey("valid").ShouldBeTrue();
}
[Fact]
public async Task LoadAllPipelinesAsync_AssignsPipelineNameFromFilename_WhenNameIsEmpty()
{
// Arrange
var directory = "/config/pipelines";
var files = new[] { "/config/pipelines/pipeline.unnamed.json" };
_fileSystem.DirectoryExists(directory).Returns(true);
_fileSystem.GetFilesAsync(directory, "pipeline.*.json", Arg.Any<CancellationToken>())
.Returns(Task.FromResult(files));
// Pipeline with empty name
_fileSystem.ReadAllTextAsync(files[0], Arg.Any<CancellationToken>())
.Returns(Task.FromResult("""{"name": "", "isEnabled": true}"""));
_fileSystem.GetFileNameWithoutExtension(files[0]).Returns("pipeline.unnamed");
// Act
var result = await _sut.LoadAllPipelinesAsync(directory);
// Assert
result.ShouldNotBeNull();
result.Count.ShouldBe(1);
result["unnamed"].Name.ShouldBe("unnamed");
}
}
@@ -0,0 +1,130 @@
using JdeScoping.ConfigManager.Models;
using JdeScoping.ConfigManager.Services;
namespace JdeScoping.ConfigManager.Tests.Services;
public class ConnectionTestServiceTests
{
private readonly ConnectionTestService _sut;
public ConnectionTestServiceTests()
{
_sut = new ConnectionTestService();
}
[Fact]
public async Task TestConnectionAsync_Oracle_ReturnsNotImplemented()
{
// Arrange
var connectionString = "Data Source=oracle;User Id=user;Password=pass;";
// Act
var result = await _sut.TestConnectionAsync(connectionString, ConnectionProvider.Oracle);
// Assert
result.Success.ShouldBeFalse();
result.Message.ShouldContain("not implemented");
}
[Fact]
public async Task TestConnectionAsync_Generic_ReturnsCannotTest()
{
// Arrange
var connectionString = "SomeGenericConnectionString";
// Act
var result = await _sut.TestConnectionAsync(connectionString, ConnectionProvider.Generic);
// Assert
result.Success.ShouldBeFalse();
result.Message.ShouldContain("Cannot test generic");
}
[Fact]
public async Task TestConnectionAsync_UnknownProvider_ReturnsUnknownProviderError()
{
// Arrange
var connectionString = "Data Source=test";
var unknownProvider = (ConnectionProvider)999;
// Act
var result = await _sut.TestConnectionAsync(connectionString, unknownProvider);
// Assert
result.Success.ShouldBeFalse();
result.Message.ShouldContain("Unknown provider");
}
[Fact]
public async Task TestConnectionAsync_SqlServer_WithInvalidConnectionString_ReturnsFailure()
{
// Arrange - Use an obviously invalid connection string that will fail fast
var connectionString = "Server=nonexistent-server-that-does-not-exist-12345;Database=TestDb;Integrated Security=true;Connect Timeout=1;";
// Act
var result = await _sut.TestConnectionAsync(connectionString, ConnectionProvider.SqlServer);
// Assert
result.Success.ShouldBeFalse();
result.Message.ShouldNotBeNullOrEmpty();
result.Duration.ShouldNotBeNull();
}
[Fact]
public async Task TestConnectionAsync_SqlServer_MeasuresDuration()
{
// Arrange - Use an invalid connection string that will fail but should still measure duration
var connectionString = "Server=nonexistent-server-12345;Database=TestDb;Integrated Security=true;Connect Timeout=1;";
// Act
var result = await _sut.TestConnectionAsync(connectionString, ConnectionProvider.SqlServer);
// Assert
result.Duration.ShouldNotBeNull();
result.Duration!.Value.ShouldBeGreaterThan(TimeSpan.Zero);
}
[Fact]
public async Task TestConnectionAsync_SqlServer_WithCancellation_ReturnsCancelledResult()
{
// Arrange
var connectionString = "Server=nonexistent-server-that-takes-forever-12345;Database=TestDb;Integrated Security=true;Connect Timeout=30;";
using var cts = new CancellationTokenSource();
cts.Cancel(); // Cancel immediately
// Act
var result = await _sut.TestConnectionAsync(connectionString, ConnectionProvider.SqlServer, cts.Token);
// Assert
result.Success.ShouldBeFalse();
result.Message.ShouldContain("cancelled");
}
[Fact]
public async Task TestConnectionAsync_SqlServer_WithMalformedConnectionString_ReturnsFailure()
{
// Arrange - Malformed connection string should be handled gracefully
var connectionString = "not a valid connection string at all!!!";
// Act
var result = await _sut.TestConnectionAsync(connectionString, ConnectionProvider.SqlServer);
// Assert
result.Success.ShouldBeFalse();
result.Message.ShouldNotBeNullOrEmpty();
}
[Fact]
public async Task TestConnectionAsync_SqlServer_WithEmptyConnectionString_ReturnsFailure()
{
// Arrange
var connectionString = "";
// Act
var result = await _sut.TestConnectionAsync(connectionString, ConnectionProvider.SqlServer);
// Assert
result.Success.ShouldBeFalse();
result.Message.ShouldNotBeNullOrEmpty();
}
}
@@ -0,0 +1,176 @@
using JdeScoping.ConfigManager.Services;
using JdeScoping.ConfigManager.Services.SecureStore;
namespace JdeScoping.ConfigManager.Tests.Services;
public class RuntimeConfigValidationServiceTests : IDisposable
{
private readonly string _testDirectory;
private readonly ISecureStoreManager _secureStoreManager;
private readonly RuntimeConfigValidationService _sut;
public RuntimeConfigValidationServiceTests()
{
_testDirectory = Path.Combine(Path.GetTempPath(), $"RuntimeConfigValidationTests_{Guid.NewGuid():N}");
Directory.CreateDirectory(_testDirectory);
_secureStoreManager = Substitute.For<ISecureStoreManager>();
_sut = new RuntimeConfigValidationService(_secureStoreManager);
}
public void Dispose()
{
if (Directory.Exists(_testDirectory))
{
Directory.Delete(_testDirectory, recursive: true);
}
}
[Fact]
public void ValidateRuntimeConfig_WithMissingAppSettings_ReturnsConfigurationError()
{
// Arrange - directory exists but no appsettings.json
var configFolder = Path.Combine(_testDirectory, "missing");
Directory.CreateDirectory(configFolder);
// Act
var results = _sut.ValidateRuntimeConfig(configFolder);
// Assert
results.ShouldNotBeEmpty();
var configResult = results.FirstOrDefault(r => r.ValidatorName == "Configuration");
configResult.ShouldNotBeNull();
configResult.IsValid.ShouldBeFalse();
configResult.Errors.ShouldContain(e => e.Contains("appsettings.json not found"));
}
[Fact]
public void ValidateRuntimeConfig_WhenSecureStoreNotOpen_AddsWarning()
{
// Arrange
var configFolder = _testDirectory;
var appSettingsPath = Path.Combine(configFolder, "appsettings.json");
File.WriteAllText(appSettingsPath, "{}");
_secureStoreManager.IsStoreOpen.Returns(false);
// Act
var results = _sut.ValidateRuntimeConfig(configFolder);
// Assert
var storeResult = results.FirstOrDefault(r => r.ValidatorName == "SecureStore");
storeResult.ShouldNotBeNull();
storeResult.Warnings.ShouldContain(w => w.Contains("No SecureStore is currently open"));
}
[Fact]
public void ValidateRuntimeConfig_WhenSecureStoreIsOpen_NoSecureStoreWarning()
{
// Arrange
var configFolder = _testDirectory;
var appSettingsPath = Path.Combine(configFolder, "appsettings.json");
File.WriteAllText(appSettingsPath, "{}");
_secureStoreManager.IsStoreOpen.Returns(true);
// Act
var results = _sut.ValidateRuntimeConfig(configFolder);
// Assert
// There may be a SecureStore result from validators, but it should not have the
// "No SecureStore is currently open" warning
var storeResults = results.Where(r => r.ValidatorName == "SecureStore").ToList();
foreach (var storeResult in storeResults)
{
storeResult.Warnings.ShouldNotContain(w => w.Contains("No SecureStore is currently open"));
}
}
[Fact]
public void ValidateRuntimeConfig_WithValidAppSettings_RunsValidators()
{
// Arrange
var configFolder = _testDirectory;
var appSettingsPath = Path.Combine(configFolder, "appsettings.json");
var validConfig = """
{
"ConnectionStrings": {},
"DataSync": {
"Enabled": true
}
}
""";
File.WriteAllText(appSettingsPath, validConfig);
_secureStoreManager.IsStoreOpen.Returns(true);
// Act
var results = _sut.ValidateRuntimeConfig(configFolder);
// Assert
// Should return results (empty or with validators) without throwing
results.ShouldNotBeNull();
}
[Fact]
public void ValidateRuntimeConfig_WithNonExistentDirectory_ReturnsConfigurationError()
{
// Arrange
var nonExistentFolder = Path.Combine(_testDirectory, "does-not-exist");
// Act
var results = _sut.ValidateRuntimeConfig(nonExistentFolder);
// Assert
results.ShouldNotBeEmpty();
var configResult = results.FirstOrDefault(r => r.ValidatorName == "Configuration");
configResult.ShouldNotBeNull();
configResult.IsValid.ShouldBeFalse();
configResult.Errors.ShouldContain(e => e.Contains("not found"));
}
[Fact]
public void ValidateRuntimeConfig_WithInvalidJson_ReturnsError()
{
// Arrange
var configFolder = _testDirectory;
var appSettingsPath = Path.Combine(configFolder, "appsettings.json");
File.WriteAllText(appSettingsPath, "{ invalid json }");
_secureStoreManager.IsStoreOpen.Returns(true);
// Act & Assert
// The service should either throw or return an error result for invalid JSON
Should.Throw<Exception>(() => _sut.ValidateRuntimeConfig(configFolder));
}
[Fact]
public void ValidateRuntimeConfig_ReturnsMultipleResults_WhenMultipleValidatorsRun()
{
// Arrange
var configFolder = _testDirectory;
var appSettingsPath = Path.Combine(configFolder, "appsettings.json");
var validConfig = """
{
"ConnectionStrings": {},
"DataSync": {
"Enabled": true
},
"SecureStore": {
"StorePath": "data/secrets.json",
"KeyFilePath": "data/secrets.key"
}
}
""";
File.WriteAllText(appSettingsPath, validConfig);
_secureStoreManager.IsStoreOpen.Returns(false);
// Act
var results = _sut.ValidateRuntimeConfig(configFolder);
// Assert
// Should have at least the SecureStore warning
results.ShouldNotBeEmpty();
}
}