refactor(configmanager): rename UI project and split test projects

Rename ConfigManager to ConfigManager.Ui to match the Core/CLI/UI project
structure, and split the monolithic test project into Core.Tests,
Cli.Tests, and Ui.Tests to align with the source project organization.
This commit is contained in:
Joseph Doherty
2026-01-28 10:24:36 -05:00
parent 7c4781dfe3
commit 1fc7792cd1
131 changed files with 267 additions and 212 deletions
@@ -0,0 +1,3 @@
global using Xunit;
global using Shouldly;
global using NSubstitute;
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Utils\JdeScoping.ConfigManager.Core\JdeScoping.ConfigManager.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Shouldly" Version="4.2.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
</ItemGroup>
</Project>
@@ -0,0 +1,189 @@
using JdeScoping.ConfigManager.Core.Models;
namespace JdeScoping.ConfigManager.Core.Tests.Models;
public class ConnectionStringEntryTests
{
[Fact]
public void GenerateConnectionString_SqlServer_ProducesCorrectFormat()
{
// Arrange
var entry = new ConnectionStringEntry
{
Provider = ConnectionProvider.SqlServer,
Server = "localhost\\SQLEXPRESS",
Database = "TestDb",
UserId = "sa",
Password = "secret123",
Encrypt = "True",
TrustServerCertificate = true,
ConnectionTimeout = 60,
ApplicationName = "TestApp"
};
// Act
var result = entry.GenerateConnectionString();
// Assert
result.ShouldContain("Server=localhost\\SQLEXPRESS");
result.ShouldContain("Database=TestDb");
result.ShouldContain("User Id=sa");
result.ShouldContain("Password=secret123");
result.ShouldContain("Encrypt=True");
result.ShouldContain("TrustServerCertificate=True");
result.ShouldContain("Connection Timeout=60");
result.ShouldContain("Application Name=TestApp");
}
[Fact]
public void GenerateConnectionString_SqlServer_OmitsDefaultTimeout()
{
// Arrange
var entry = new ConnectionStringEntry
{
Provider = ConnectionProvider.SqlServer,
Server = "localhost",
Database = "TestDb",
UserId = "user",
Password = "pass",
ConnectionTimeout = 30 // Default value
};
// Act
var result = entry.GenerateConnectionString();
// Assert
result.ShouldNotContain("Connection Timeout=");
}
[Fact]
public void GenerateConnectionString_Oracle_ProducesEZConnectFormat()
{
// Arrange
var entry = new ConnectionStringEntry
{
Provider = ConnectionProvider.Oracle,
Host = "oracle-server.example.com",
Port = 1522,
ServiceName = "ORCL",
UserId = "scott",
Password = "tiger"
};
// Act
var result = entry.GenerateConnectionString();
// Assert
result.ShouldContain("Data Source=//oracle-server.example.com:1522/ORCL");
result.ShouldContain("User Id=scott");
result.ShouldContain("Password=tiger");
}
[Fact]
public void GenerateConnectionString_Generic_ReturnsRawString()
{
// Arrange
var rawConnString = "Driver={ODBC Driver};Server=myserver;Database=mydb;";
var entry = new ConnectionStringEntry
{
Provider = ConnectionProvider.Generic,
RawConnectionString = rawConnString
};
// Act
var result = entry.GenerateConnectionString();
// Assert
result.ShouldBe(rawConnString);
}
[Fact]
public void DefaultValues_AreCorrect()
{
// Act
var entry = new ConnectionStringEntry();
// Assert
entry.Name.ShouldBe(string.Empty);
entry.Provider.ShouldBe(ConnectionProvider.Generic);
entry.Server.ShouldBeNull();
entry.SqlServerPort.ShouldBeNull();
entry.Database.ShouldBeNull();
entry.UserId.ShouldBeNull();
entry.Password.ShouldBeNull();
entry.Encrypt.ShouldBe("True");
entry.TrustServerCertificate.ShouldBeFalse();
entry.ConnectionTimeout.ShouldBe(30);
entry.ApplicationName.ShouldBeNull();
entry.Host.ShouldBeNull();
entry.Port.ShouldBe(1521);
entry.ServiceName.ShouldBeNull();
entry.RawConnectionString.ShouldBeNull();
}
[Fact]
public void GenerateConnectionString_SqlServer_WithPort_IncludesPortInServer()
{
// Arrange
var entry = new ConnectionStringEntry
{
Provider = ConnectionProvider.SqlServer,
Server = "localhost",
SqlServerPort = 1434,
Database = "TestDb",
UserId = "sa",
Password = "secret123"
};
// Act
var result = entry.GenerateConnectionString();
// Assert
result.ShouldContain("Server=localhost,1434");
result.ShouldContain("Database=TestDb");
}
[Fact]
public void GenerateConnectionString_SqlServer_WithoutPort_NoPortInServer()
{
// Arrange
var entry = new ConnectionStringEntry
{
Provider = ConnectionProvider.SqlServer,
Server = "localhost",
SqlServerPort = null,
Database = "TestDb",
UserId = "sa",
Password = "secret123"
};
// Act
var result = entry.GenerateConnectionString();
// Assert
result.ShouldContain("Server=localhost;");
result.ShouldNotContain("Server=localhost,");
}
[Fact]
public void GenerateConnectionString_SqlServer_WithZeroPort_NoPortInServer()
{
// Arrange
var entry = new ConnectionStringEntry
{
Provider = ConnectionProvider.SqlServer,
Server = "localhost",
SqlServerPort = 0,
Database = "TestDb",
UserId = "sa",
Password = "secret123"
};
// Act
var result = entry.GenerateConnectionString();
// Assert
result.ShouldContain("Server=localhost;");
result.ShouldNotContain("Server=localhost,");
}
}
@@ -0,0 +1,278 @@
using System.Text.Json;
using JdeScoping.ConfigManager.Core.Models;
using Shouldly;
using Xunit;
namespace JdeScoping.ConfigManager.Core.Tests.Models;
public class ConnectionStringsSectionConverterTests
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
[Fact]
public void Deserialize_StandardDictionaryFormat_ParsesAllConnections()
{
// Arrange
var json = """
{
"LotFinder": "Server=localhost,1434;Database=ScopingTool;User Id=sa;Password=test",
"JDE": "Data Source=jde-server:1521/JDEPROD;User Id=jdeuser;Password=jdepass",
"CMS": "Data Source=cms-server:1521/CMSPROD;User Id=cmsuser;Password=cmspass"
}
""";
// Act
var section = JsonSerializer.Deserialize<ConnectionStringsSection>(json, JsonOptions);
// Assert
section.ShouldNotBeNull();
section.Entries.Count.ShouldBe(3);
var lotFinder = section.Entries.First(e => e.Name == "LotFinder");
lotFinder.Provider.ShouldBe(ConnectionProvider.SqlServer);
lotFinder.Server.ShouldBe("localhost,1434"); // Port embedded in server, not parsed separately
lotFinder.SqlServerPort.ShouldBeNull();
lotFinder.Database.ShouldBe("ScopingTool");
lotFinder.UserId.ShouldBe("sa");
var jde = section.Entries.First(e => e.Name == "JDE");
jde.Provider.ShouldBe(ConnectionProvider.Oracle);
jde.Host.ShouldBe("jde-server");
jde.Port.ShouldBe(1521);
jde.ServiceName.ShouldBe("JDEPROD");
}
[Fact]
public void Deserialize_SqlServerConnection_ParsesAllFields()
{
// Arrange
var json = """
{
"TestDb": "Server=myserver;Database=TestDB;User Id=testuser;Password=testpass;Encrypt=True;TrustServerCertificate=True;Connection Timeout=60;Application Name=TestApp"
}
""";
// Act
var section = JsonSerializer.Deserialize<ConnectionStringsSection>(json, JsonOptions);
// Assert
section.ShouldNotBeNull();
section.Entries.Count.ShouldBe(1);
var entry = section.Entries[0];
entry.Name.ShouldBe("TestDb");
entry.Provider.ShouldBe(ConnectionProvider.SqlServer);
entry.Server.ShouldBe("myserver");
entry.Database.ShouldBe("TestDB");
entry.UserId.ShouldBe("testuser");
entry.Password.ShouldBe("testpass");
entry.Encrypt.ShouldBe("True");
entry.TrustServerCertificate.ShouldBeTrue();
entry.ConnectionTimeout.ShouldBe(60);
entry.ApplicationName.ShouldBe("TestApp");
}
[Fact]
public void Deserialize_OracleConnection_ParsesHostPortService()
{
// Arrange
var json = """
{
"Oracle": "Data Source=//db-host:1523/PRODDB;User Id=orauser;Password=orapass"
}
""";
// Act
var section = JsonSerializer.Deserialize<ConnectionStringsSection>(json, JsonOptions);
// Assert
section.ShouldNotBeNull();
section.Entries.Count.ShouldBe(1);
var entry = section.Entries[0];
entry.Name.ShouldBe("Oracle");
entry.Provider.ShouldBe(ConnectionProvider.Oracle);
entry.Host.ShouldBe("db-host");
entry.Port.ShouldBe(1523);
entry.ServiceName.ShouldBe("PRODDB");
entry.UserId.ShouldBe("orauser");
entry.Password.ShouldBe("orapass");
}
[Fact]
public void Deserialize_EmptyObject_ReturnsEmptyEntries()
{
// Arrange
var json = "{}";
// Act
var section = JsonSerializer.Deserialize<ConnectionStringsSection>(json, JsonOptions);
// Assert
section.ShouldNotBeNull();
section.Entries.ShouldBeEmpty();
}
[Fact]
public void Serialize_ToStandardDictionaryFormat()
{
// Arrange
var section = new ConnectionStringsSection
{
Entries = new List<ConnectionStringEntry>
{
new()
{
Name = "TestDb",
Provider = ConnectionProvider.SqlServer,
Server = "myserver",
Database = "TestDB",
UserId = "testuser",
Password = "testpass"
}
}
};
// Act
var json = JsonSerializer.Serialize(section, JsonOptions);
// Assert
json.ShouldNotBeNull();
json.ShouldContain("\"TestDb\"");
json.ShouldContain("Server=myserver");
json.ShouldContain("Database=TestDB");
}
[Fact]
public void RoundTrip_PreservesConnections()
{
// Arrange
var original = new ConnectionStringsSection
{
Entries = new List<ConnectionStringEntry>
{
new()
{
Name = "Primary",
Provider = ConnectionProvider.SqlServer,
Server = "server1",
Database = "DB1",
UserId = "user1",
Password = "pass1"
},
new()
{
Name = "Secondary",
Provider = ConnectionProvider.Oracle,
Host = "oracle-host",
Port = 1521,
ServiceName = "ORCL",
UserId = "user2",
Password = "pass2"
}
}
};
// Act
var json = JsonSerializer.Serialize(original, JsonOptions);
var deserialized = JsonSerializer.Deserialize<ConnectionStringsSection>(json, JsonOptions);
// Assert
deserialized.ShouldNotBeNull();
deserialized.Entries.Count.ShouldBe(2);
var primary = deserialized.Entries.First(e => e.Name == "Primary");
primary.Provider.ShouldBe(ConnectionProvider.SqlServer);
primary.Server.ShouldBe("server1");
primary.Database.ShouldBe("DB1");
var secondary = deserialized.Entries.First(e => e.Name == "Secondary");
secondary.Provider.ShouldBe(ConnectionProvider.Oracle);
secondary.Host.ShouldBe("oracle-host");
secondary.Port.ShouldBe(1521);
secondary.ServiceName.ShouldBe("ORCL");
}
[Fact]
public void Deserialize_SqlServerWithEmbeddedPort_KeepsPortInServer()
{
// Arrange - connection string with port embedded in server name (legacy format)
var json = """
{
"TestDb": "Server=localhost,1434;Database=TestDB;User Id=testuser;Password=testpass"
}
""";
// Act
var section = JsonSerializer.Deserialize<ConnectionStringsSection>(json, JsonOptions);
// Assert - port is NOT parsed out, stays embedded in server name
section.ShouldNotBeNull();
section.Entries.Count.ShouldBe(1);
var entry = section.Entries[0];
entry.Name.ShouldBe("TestDb");
entry.Provider.ShouldBe(ConnectionProvider.SqlServer);
entry.Server.ShouldBe("localhost,1434"); // Port stays in server name
entry.SqlServerPort.ShouldBeNull(); // Port control not populated from parsing
entry.Database.ShouldBe("TestDB");
}
[Fact]
public void Deserialize_SqlServerWithoutPort_ServerHasNoPort()
{
// Arrange
var json = """
{
"TestDb": "Server=myserver;Database=TestDB;User Id=testuser;Password=testpass"
}
""";
// Act
var section = JsonSerializer.Deserialize<ConnectionStringsSection>(json, JsonOptions);
// Assert
section.ShouldNotBeNull();
section.Entries.Count.ShouldBe(1);
var entry = section.Entries[0];
entry.Server.ShouldBe("myserver");
entry.SqlServerPort.ShouldBeNull();
}
[Fact]
public void RoundTrip_SqlServerWithPort_EmbedsPortInServer()
{
// Arrange - entry with separate port control
var original = new ConnectionStringsSection
{
Entries = new List<ConnectionStringEntry>
{
new()
{
Name = "WithPort",
Provider = ConnectionProvider.SqlServer,
Server = "localhost",
SqlServerPort = 1434,
Database = "DB1",
UserId = "user1",
Password = "pass1"
}
}
};
// Act
var json = JsonSerializer.Serialize(original, JsonOptions);
var deserialized = JsonSerializer.Deserialize<ConnectionStringsSection>(json, JsonOptions);
// Assert - after round trip, port is embedded in server name (not parsed back out)
deserialized.ShouldNotBeNull();
var entry = deserialized.Entries.First();
entry.Server.ShouldBe("localhost,1434"); // Port embedded in server after round trip
entry.SqlServerPort.ShouldBeNull(); // Port control not populated from parsing
}
}
@@ -0,0 +1,210 @@
using JdeScoping.ConfigManager.Core.Services;
namespace JdeScoping.ConfigManager.Core.Tests.Services;
public class AutoDiscoveryServiceTests : IDisposable
{
private readonly IFileSystem _fileSystem;
private readonly AutoDiscoveryService _sut;
private readonly string? _originalEnvVar;
public AutoDiscoveryServiceTests()
{
_fileSystem = Substitute.For<IFileSystem>();
_sut = new AutoDiscoveryService(_fileSystem);
// Save original env var to restore later
_originalEnvVar = Environment.GetEnvironmentVariable("JDESCOPING_CONFIG_PATH");
// Set up default Combine behavior to work like Path.Combine
_fileSystem.Combine(Arg.Any<string[]>()).Returns(callInfo =>
{
var paths = callInfo.Arg<string[]>();
return Path.Combine(paths);
});
}
public void Dispose()
{
// Restore original env var
Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", _originalEnvVar);
}
[Fact]
public async Task FindConfigFolderAsync_WhenEnvVarSet_ReturnsEnvPath()
{
// Arrange
Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", "/custom/config");
_fileSystem.DirectoryExists("/custom/config").Returns(true);
_fileSystem.FileExists(Arg.Is<string>(s => s.Contains("appsettings.json") && s.Contains("/custom/config"))).Returns(true);
// Act
var result = await _sut.FindConfigFolderAsync();
// Assert
result.ShouldBe("/custom/config");
}
[Fact]
public async Task FindConfigFolderAsync_WhenEnvVarSetButDirectoryInvalid_SearchesOtherLocations()
{
// Arrange
Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", "/invalid/config");
_fileSystem.DirectoryExists(Arg.Any<string>()).Returns(false);
_fileSystem.FileExists(Arg.Any<string>()).Returns(false);
// Act
var result = await _sut.FindConfigFolderAsync();
// Assert
result.ShouldBeNull();
}
[Fact]
public async Task FindConfigFolderAsync_WhenEnvVarSetButNoAppSettings_SearchesOtherLocations()
{
// Arrange
Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", "/custom/config");
// Directory exists but no appsettings.json
_fileSystem.DirectoryExists("/custom/config").Returns(true);
_fileSystem.FileExists(Arg.Any<string>()).Returns(false);
// All other locations also don't have config
_fileSystem.DirectoryExists(Arg.Is<string>(s => s != "/custom/config")).Returns(false);
// Act
var result = await _sut.FindConfigFolderAsync();
// Assert
result.ShouldBeNull();
}
[Fact]
public async Task FindConfigFolderAsync_WhenNotFound_ReturnsNull()
{
// Arrange
Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", null);
_fileSystem.DirectoryExists(Arg.Any<string>()).Returns(false);
_fileSystem.FileExists(Arg.Any<string>()).Returns(false);
// Act
var result = await _sut.FindConfigFolderAsync();
// Assert
result.ShouldBeNull();
}
[Fact]
public async Task FindConfigFolderAsync_WhenExeDirectoryHasConfig_ReturnsExeDirectory()
{
// Arrange
Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", null);
var exeDir = AppContext.BaseDirectory;
_fileSystem.DirectoryExists(exeDir).Returns(true);
_fileSystem.FileExists(Arg.Is<string>(s => s.Contains(exeDir) && s.Contains("appsettings.json"))).Returns(true);
// Act
var result = await _sut.FindConfigFolderAsync();
// Assert
result.ShouldBe(exeDir);
}
[Fact]
public async Task FindConfigFolderAsync_WhenHostDirectoryHasConfig_ReturnsHostDirectory()
{
// Arrange
Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", null);
var exeDir = AppContext.BaseDirectory;
var hostDir = Path.Combine(exeDir, "..", "JdeScoping.Host");
// Exe directory doesn't have config
_fileSystem.DirectoryExists(exeDir).Returns(false);
// Host directory has config
_fileSystem.DirectoryExists(hostDir).Returns(true);
_fileSystem.FileExists(Arg.Is<string>(s => s.Contains("JdeScoping.Host") && s.Contains("appsettings.json"))).Returns(true);
// Other directories don't have config
_fileSystem.DirectoryExists(Arg.Is<string>(s => s != exeDir && s != hostDir)).Returns(false);
// Act
var result = await _sut.FindConfigFolderAsync();
// Assert
result.ShouldBe(hostDir);
}
[Fact]
public async Task FindConfigFolderAsync_WhenUserConfigDirectoryHasConfig_ReturnsUserConfigDirectory()
{
// Arrange
Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", null);
var exeDir = AppContext.BaseDirectory;
var hostDir = Path.Combine(exeDir, "..", "JdeScoping.Host");
// Determine user config path based on OS
string userConfigDir;
if (OperatingSystem.IsWindows())
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
userConfigDir = Path.Combine(localAppData, "JdeScoping");
}
else
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
userConfigDir = Path.Combine(home, ".jdescoping");
}
// Exe directory doesn't have config
_fileSystem.DirectoryExists(exeDir).Returns(false);
// Host directory doesn't have config
_fileSystem.DirectoryExists(hostDir).Returns(false);
// User config directory has config
_fileSystem.DirectoryExists(userConfigDir).Returns(true);
_fileSystem.FileExists(Arg.Is<string>(s => s.Contains(userConfigDir) && s.Contains("appsettings.json"))).Returns(true);
// Act
var result = await _sut.FindConfigFolderAsync();
// Assert
result.ShouldBe(userConfigDir);
}
[Fact]
public async Task FindConfigFolderAsync_SearchesPrioritizedOrder()
{
// Arrange - all locations have config, but env var should be returned first
Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", "/env/config");
var exeDir = AppContext.BaseDirectory;
// Both env var path and exe dir have valid config
_fileSystem.DirectoryExists("/env/config").Returns(true);
_fileSystem.DirectoryExists(exeDir).Returns(true);
_fileSystem.FileExists(Arg.Any<string>()).Returns(true);
// Act
var result = await _sut.FindConfigFolderAsync();
// Assert - should return env var path, not exe dir
result.ShouldBe("/env/config");
}
[Fact]
public async Task FindConfigFolderAsync_WithCancellationToken_DoesNotThrow()
{
// Arrange
Environment.SetEnvironmentVariable("JDESCOPING_CONFIG_PATH", "/custom/config");
_fileSystem.DirectoryExists("/custom/config").Returns(true);
_fileSystem.FileExists(Arg.Any<string>()).Returns(true);
using var cts = new CancellationTokenSource();
// Act
var result = await _sut.FindConfigFolderAsync(cts.Token);
// Assert
result.ShouldBe("/custom/config");
}
}
@@ -0,0 +1,237 @@
using JdeScoping.ConfigManager.Core.Services;
namespace JdeScoping.ConfigManager.Core.Tests.Services;
public class BackupServiceTests
{
private readonly IFileSystem _fileSystem;
private readonly BackupService _sut;
public BackupServiceTests()
{
_fileSystem = Substitute.For<IFileSystem>();
_sut = new BackupService(_fileSystem);
}
[Fact]
public async Task CreateBackupAsync_CreatesTimestampedBackup()
{
// Arrange
var sourcePath = "/config/appsettings.json";
_fileSystem.FileExists(sourcePath).Returns(true);
_fileSystem.GetDirectoryName(sourcePath).Returns("/config");
_fileSystem.GetFileNameWithoutExtension(sourcePath).Returns("appsettings");
_fileSystem.Combine(Arg.Any<string[]>()).Returns(callInfo =>
{
var paths = callInfo.Arg<string[]>();
return string.Join("/", paths);
});
// Act
var backupPath = await _sut.CreateBackupAsync(sourcePath);
// Assert
backupPath.ShouldStartWith("/config/appsettings.");
backupPath.ShouldEndWith(".bak");
await _fileSystem.Received(1).CopyFileAsync(sourcePath, backupPath, Arg.Any<CancellationToken>());
}
[Fact]
public async Task CleanupOldBackupsAsync_KeepsOnlySpecifiedCount()
{
// Arrange
var filePath = "/config/appsettings.json";
_fileSystem.GetDirectoryName(filePath).Returns("/config");
_fileSystem.GetFileNameWithoutExtension(filePath).Returns("appsettings");
var backups = Enumerable.Range(1, 15)
.Select(i => $"/config/appsettings.2026-01-{i:D2}_120000.bak")
.ToArray();
_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
await _sut.CleanupOldBackupsAsync(filePath, keepCount: 10);
// 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>());
}
}
@@ -0,0 +1,272 @@
using JdeScoping.ConfigManager.Core.Models;
using JdeScoping.ConfigManager.Core.Services;
using JdeScoping.DataSync.Configuration;
namespace JdeScoping.ConfigManager.Core.Tests.Services;
public class ConfigFileServiceTests
{
private readonly IFileSystem _fileSystem;
private readonly ConfigFileService _sut;
public ConfigFileServiceTests()
{
_fileSystem = Substitute.For<IFileSystem>();
_sut = new ConfigFileService(_fileSystem);
}
[Fact]
public async Task LoadAppSettingsAsync_WithValidJson_ReturnsConfigModel()
{
// Arrange
var json = """
{
"DataSync": {
"Enabled": true,
"MaxDegreeOfParallelism": 8
}
}
""";
_fileSystem.ReadAllTextAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(json));
// Act
var result = await _sut.LoadAppSettingsAsync("/config/appsettings.json");
// Assert
result.ShouldNotBeNull();
result.DataSync.Enabled.ShouldBeTrue();
result.DataSync.MaxDegreeOfParallelism.ShouldBe(8);
}
[Fact]
public async Task LoadAppSettingsAsync_WithInvalidJson_ThrowsWithHelpfulMessage()
{
// 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.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.Core.Models;
using JdeScoping.ConfigManager.Core.Services;
namespace JdeScoping.ConfigManager.Core.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,173 @@
using JdeScoping.ConfigManager.Core.Services;
namespace JdeScoping.ConfigManager.Core.Tests.Services;
public class DiffServiceTests
{
private readonly DiffService _sut;
public DiffServiceTests()
{
_sut = new DiffService();
}
[Fact]
public void GenerateDiff_WithNoChanges_ReturnsEmptyDiff()
{
// Arrange
var original = "line1\nline2\nline3";
var modified = "line1\nline2\nline3";
// Act
var result = _sut.GenerateDiff(original, modified);
// Assert
result.HasChanges.ShouldBeFalse();
}
[Fact]
public void GenerateDiff_WithChanges_ReturnsDiffLines()
{
// Arrange
var original = "line1\nline2\nline3";
var modified = "line1\nmodified\nline3";
// Act
var result = _sut.GenerateDiff(original, modified);
// Assert
result.HasChanges.ShouldBeTrue();
result.Lines.ShouldNotBeEmpty();
}
[Fact]
public void GenerateDiff_WithAddedLine_ReportsInsertion()
{
// Arrange
var original = "line1\nline2";
var modified = "line1\nline2\nline3";
// Act
var result = _sut.GenerateDiff(original, modified);
// Assert
result.HasChanges.ShouldBeTrue();
result.Insertions.ShouldBe(1);
result.Deletions.ShouldBe(0);
result.Lines.ShouldContain(l => l.Type == DiffLineType.Added && l.Text == "line3");
}
[Fact]
public void GenerateDiff_WithRemovedLine_ReportsDeletion()
{
// Arrange
var original = "line1\nline2\nline3";
var modified = "line1\nline3";
// Act
var result = _sut.GenerateDiff(original, modified);
// Assert
result.HasChanges.ShouldBeTrue();
result.Deletions.ShouldBe(1);
result.Lines.ShouldContain(l => l.Type == DiffLineType.Removed && l.Text == "line2");
}
[Fact]
public void GenerateDiff_WithModifiedLine_ReportsAdditionAndDeletion()
{
// Arrange
var original = "line1\noriginal\nline3";
var modified = "line1\nchanged\nline3";
// Act
var result = _sut.GenerateDiff(original, modified);
// Assert
result.HasChanges.ShouldBeTrue();
result.Insertions.ShouldBeGreaterThan(0);
result.Deletions.ShouldBeGreaterThan(0);
}
[Fact]
public void GenerateDiff_WithEmptyOriginal_ReportsAllAsInsertions()
{
// Arrange
var original = "";
var modified = "line1\nline2";
// Act
var result = _sut.GenerateDiff(original, modified);
// Assert
result.HasChanges.ShouldBeTrue();
result.Insertions.ShouldBeGreaterThan(0);
result.Deletions.ShouldBe(0);
}
[Fact]
public void GenerateDiff_WithEmptyModified_ReportsAllAsDeletions()
{
// Arrange
var original = "line1\nline2";
var modified = "";
// Act
var result = _sut.GenerateDiff(original, modified);
// Assert
result.HasChanges.ShouldBeTrue();
result.Insertions.ShouldBe(0);
result.Deletions.ShouldBeGreaterThan(0);
}
[Fact]
public void GenerateDiff_LineNumbers_AreCorrectForUnchangedLines()
{
// Arrange
var original = "line1\nline2\nline3";
var modified = "line1\nline2\nline3";
// Act
var result = _sut.GenerateDiff(original, modified);
// Assert
var firstLine = result.Lines.First();
firstLine.OldLineNumber.ShouldBe(1);
firstLine.NewLineNumber.ShouldBe(1);
}
[Fact]
public void GenerateDiff_AddedLine_HasNullOldLineNumber()
{
// Arrange
var original = "line1";
var modified = "line1\nline2";
// Act
var result = _sut.GenerateDiff(original, modified);
// Assert
var addedLine = result.Lines.FirstOrDefault(l => l.Type == DiffLineType.Added);
addedLine.ShouldNotBeNull();
addedLine.OldLineNumber.ShouldBeNull();
addedLine.NewLineNumber.ShouldNotBeNull();
}
[Fact]
public void GenerateDiff_RemovedLine_HasNullNewLineNumber()
{
// Arrange
var original = "line1\nline2";
var modified = "line1";
// Act
var result = _sut.GenerateDiff(original, modified);
// Assert
var removedLine = result.Lines.FirstOrDefault(l => l.Type == DiffLineType.Removed);
removedLine.ShouldNotBeNull();
removedLine.OldLineNumber.ShouldNotBeNull();
removedLine.NewLineNumber.ShouldBeNull();
}
}
@@ -0,0 +1,40 @@
using JdeScoping.ConfigManager.Core.Services;
namespace JdeScoping.ConfigManager.Core.Tests.Services;
public class FileSystemTests
{
[Fact]
public void FileExists_WithExistingFile_ReturnsTrue()
{
// Arrange
var sut = new FileSystem();
var tempFile = Path.GetTempFileName();
try
{
// Act
var result = sut.FileExists(tempFile);
// Assert
result.ShouldBeTrue();
}
finally
{
File.Delete(tempFile);
}
}
[Fact]
public void FileExists_WithNonExistingFile_ReturnsFalse()
{
// Arrange
var sut = new FileSystem();
// Act
var result = sut.FileExists("/nonexistent/path/file.txt");
// Assert
result.ShouldBeFalse();
}
}
@@ -0,0 +1,176 @@
using JdeScoping.ConfigManager.Core.Services;
using JdeScoping.ConfigManager.Core.Services.SecureStore;
namespace JdeScoping.ConfigManager.Core.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();
}
}
@@ -0,0 +1,432 @@
using System.IO;
using JdeScoping.ConfigManager.Core.Services.SecureStore;
namespace JdeScoping.ConfigManager.Core.Tests.Services.SecureStore;
public class SecureStoreManagerTests : IDisposable
{
private readonly string _testDirectory;
private readonly SecureStoreManager _sut;
public SecureStoreManagerTests()
{
_testDirectory = Path.Combine(Path.GetTempPath(), $"SecureStoreTests_{Guid.NewGuid():N}");
Directory.CreateDirectory(_testDirectory);
_sut = new SecureStoreManager();
}
public void Dispose()
{
_sut.Dispose();
if (Directory.Exists(_testDirectory))
{
Directory.Delete(_testDirectory, recursive: true);
}
}
[Fact]
public void IsStoreOpen_WhenNoStoreOpen_ReturnsFalse()
{
_sut.IsStoreOpen.ShouldBeFalse();
}
[Fact]
public void CurrentStorePath_WhenNoStoreOpen_ReturnsNull()
{
_sut.CurrentStorePath.ShouldBeNull();
}
[Fact]
public void HasUnsavedChanges_WhenNoStoreOpen_ReturnsFalse()
{
_sut.HasUnsavedChanges.ShouldBeFalse();
}
[Fact]
public void CreateStore_WithKeyFile_CreatesStoreAndKeyFile()
{
// Arrange
var storePath = Path.Combine(_testDirectory, "test.json");
var keyPath = Path.Combine(_testDirectory, "test.key");
// Act
_sut.CreateStore(storePath, keyPath);
// Assert
_sut.IsStoreOpen.ShouldBeTrue();
_sut.CurrentStorePath.ShouldBe(storePath);
File.Exists(storePath).ShouldBeTrue();
File.Exists(keyPath).ShouldBeTrue();
}
[Fact]
public void OpenStore_WithValidKeyFile_OpensStore()
{
// Arrange
var storePath = Path.Combine(_testDirectory, "test.json");
var keyPath = Path.Combine(_testDirectory, "test.key");
_sut.CreateStore(storePath, keyPath);
_sut.CloseStore();
// Act
_sut.OpenStore(storePath, keyPath);
// Assert
_sut.IsStoreOpen.ShouldBeTrue();
_sut.CurrentStorePath.ShouldBe(storePath);
}
[Fact]
public void OpenStore_WithNonExistentStore_ThrowsFileNotFoundException()
{
// Arrange
var storePath = Path.Combine(_testDirectory, "nonexistent.json");
var keyPath = Path.Combine(_testDirectory, "test.key");
// Act & Assert
Should.Throw<FileNotFoundException>(() => _sut.OpenStore(storePath, keyPath));
}
[Fact]
public void CloseStore_ClosesOpenStore()
{
// Arrange
var storePath = Path.Combine(_testDirectory, "test.json");
var keyPath = Path.Combine(_testDirectory, "test.key");
_sut.CreateStore(storePath, keyPath);
// Act
_sut.CloseStore();
// Assert
_sut.IsStoreOpen.ShouldBeFalse();
_sut.CurrentStorePath.ShouldBeNull();
}
[Fact]
public void SetSecret_AddsSecretAndMarksUnsaved()
{
// Arrange
var storePath = Path.Combine(_testDirectory, "test.json");
var keyPath = Path.Combine(_testDirectory, "test.key");
_sut.CreateStore(storePath, keyPath);
_sut.Save(); // Save to clear unsaved flag
// Act
_sut.SetSecret("testKey", "testValue");
// Assert
_sut.HasUnsavedChanges.ShouldBeTrue();
_sut.GetKeys().ShouldContain("testKey");
}
[Fact]
public void GetSecret_ReturnsCorrectValue()
{
// Arrange
var storePath = Path.Combine(_testDirectory, "test.json");
var keyPath = Path.Combine(_testDirectory, "test.key");
_sut.CreateStore(storePath, keyPath);
_sut.SetSecret("testKey", "testValue");
// Act
var value = _sut.GetSecret("testKey");
// Assert
value.ShouldBe("testValue");
}
[Fact]
public void GetSecret_WhenKeyNotFound_ThrowsKeyNotFoundException()
{
// Arrange
var storePath = Path.Combine(_testDirectory, "test.json");
var keyPath = Path.Combine(_testDirectory, "test.key");
_sut.CreateStore(storePath, keyPath);
// Act & Assert
Should.Throw<KeyNotFoundException>(() => _sut.GetSecret("nonexistent"));
}
[Fact]
public void RemoveSecret_RemovesSecretAndMarksUnsaved()
{
// Arrange
var storePath = Path.Combine(_testDirectory, "test.json");
var keyPath = Path.Combine(_testDirectory, "test.key");
_sut.CreateStore(storePath, keyPath);
_sut.SetSecret("testKey", "testValue");
_sut.Save();
// Act
_sut.RemoveSecret("testKey");
// Assert
_sut.HasUnsavedChanges.ShouldBeTrue();
_sut.GetKeys().ShouldNotContain("testKey");
}
[Fact]
public void RemoveSecret_WhenKeyNotFound_ThrowsKeyNotFoundException()
{
// Arrange
var storePath = Path.Combine(_testDirectory, "test.json");
var keyPath = Path.Combine(_testDirectory, "test.key");
_sut.CreateStore(storePath, keyPath);
// Act & Assert
Should.Throw<KeyNotFoundException>(() => _sut.RemoveSecret("nonexistent"));
}
[Fact]
public void Save_PersistsSecretsToStore()
{
// Arrange
var storePath = Path.Combine(_testDirectory, "test.json");
var keyPath = Path.Combine(_testDirectory, "test.key");
_sut.CreateStore(storePath, keyPath);
_sut.SetSecret("testKey", "testValue");
// Act
_sut.Save();
_sut.CloseStore();
_sut.OpenStore(storePath, keyPath);
// Assert
_sut.GetKeys().ShouldContain("testKey");
_sut.GetSecret("testKey").ShouldBe("testValue");
}
[Fact]
public void Save_ClearsUnsavedChangesFlag()
{
// Arrange
var storePath = Path.Combine(_testDirectory, "test.json");
var keyPath = Path.Combine(_testDirectory, "test.key");
_sut.CreateStore(storePath, keyPath);
_sut.SetSecret("testKey", "testValue");
_sut.HasUnsavedChanges.ShouldBeTrue();
// Act
_sut.Save();
// Assert
_sut.HasUnsavedChanges.ShouldBeFalse();
}
[Fact]
public void GetKeys_ReturnsAllSecretKeys()
{
// Arrange
var storePath = Path.Combine(_testDirectory, "test.json");
var keyPath = Path.Combine(_testDirectory, "test.key");
_sut.CreateStore(storePath, keyPath);
_sut.SetSecret("key1", "value1");
_sut.SetSecret("key2", "value2");
_sut.SetSecret("key3", "value3");
// Act
var keys = _sut.GetKeys();
// Assert
keys.Count.ShouldBe(3);
keys.ShouldContain("key1");
keys.ShouldContain("key2");
keys.ShouldContain("key3");
}
[Fact]
public void GenerateKeyFile_CreatesNewKeyFile()
{
// Arrange
var keyPath = Path.Combine(_testDirectory, "generated.key");
// Act
_sut.GenerateKeyFile(keyPath);
// Assert
File.Exists(keyPath).ShouldBeTrue();
}
[Fact]
public void ExportKey_WhenNoStoreOpen_ThrowsInvalidOperationException()
{
// Arrange
var keyPath = Path.Combine(_testDirectory, "export.key");
// Act & Assert
Should.Throw<InvalidOperationException>(() => _sut.ExportKey(keyPath));
}
[Fact]
public void ExportKey_WhenStoreOpen_ExportsKey()
{
// Arrange
var storePath = Path.Combine(_testDirectory, "test.json");
var keyPath = Path.Combine(_testDirectory, "test.key");
var exportPath = Path.Combine(_testDirectory, "export.key");
_sut.CreateStore(storePath, keyPath);
// Act
_sut.ExportKey(exportPath);
// Assert
File.Exists(exportPath).ShouldBeTrue();
}
[Fact]
public void GetKeys_WhenNoStoreOpen_ThrowsInvalidOperationException()
{
// Act & Assert
Should.Throw<InvalidOperationException>(() => _sut.GetKeys());
}
[Fact]
public void SetSecret_WhenNoStoreOpen_ThrowsInvalidOperationException()
{
// Act & Assert
Should.Throw<InvalidOperationException>(() => _sut.SetSecret("key", "value"));
}
[Fact]
public void GetSecret_WhenNoStoreOpen_ThrowsInvalidOperationException()
{
// Act & Assert
Should.Throw<InvalidOperationException>(() => _sut.GetSecret("key"));
}
[Fact]
public void EnsureAllRequiredEntries_AddsMissingKeys()
{
// Arrange
var storePath = Path.Combine(_testDirectory, "test.json");
var keyPath = Path.Combine(_testDirectory, "test.key");
_sut.CreateStore(storePath, keyPath);
_sut.SetSecret("existingKey", "existingValue");
_sut.Save();
// Act
var addedKeys = _sut.EnsureAllRequiredEntries(
new[] { "existingKey", "newKey1" },
new[] { "LotFinder", "JDE" });
// Assert
addedKeys.Count.ShouldBe(3);
addedKeys.ShouldContain("newKey1");
addedKeys.ShouldContain("LotFinder");
addedKeys.ShouldContain("JDE");
addedKeys.ShouldNotContain("existingKey");
// Verify all keys exist
_sut.GetKeys().ShouldContain("existingKey");
_sut.GetKeys().ShouldContain("newKey1");
_sut.GetKeys().ShouldContain("LotFinder");
_sut.GetKeys().ShouldContain("JDE");
}
[Fact]
public void EnsureAllRequiredEntries_CreatesEmptyValues()
{
// Arrange
var storePath = Path.Combine(_testDirectory, "test.json");
var keyPath = Path.Combine(_testDirectory, "test.key");
_sut.CreateStore(storePath, keyPath);
// Act
_sut.EnsureAllRequiredEntries(
new[] { "newKey" },
Array.Empty<string>());
// Assert
_sut.GetSecret("newKey").ShouldBe(string.Empty);
}
[Fact]
public void EnsureAllRequiredEntries_DoesNotOverwriteExistingValues()
{
// Arrange
var storePath = Path.Combine(_testDirectory, "test.json");
var keyPath = Path.Combine(_testDirectory, "test.key");
_sut.CreateStore(storePath, keyPath);
_sut.SetSecret("existingKey", "originalValue");
_sut.Save();
// Act
var addedKeys = _sut.EnsureAllRequiredEntries(
new[] { "existingKey" },
Array.Empty<string>());
// Assert
addedKeys.ShouldBeEmpty(); // Key already existed
_sut.GetSecret("existingKey").ShouldBe("originalValue"); // Value preserved
}
[Fact]
public void EnsureAllRequiredEntries_AutoSavesWhenKeysAdded()
{
// Arrange
var storePath = Path.Combine(_testDirectory, "test.json");
var keyPath = Path.Combine(_testDirectory, "test.key");
_sut.CreateStore(storePath, keyPath);
// Act
_sut.EnsureAllRequiredEntries(
new[] { "newKey" },
Array.Empty<string>());
// Assert - should auto-save (HasUnsavedChanges should be false after calling EnsureAllRequiredEntries)
_sut.HasUnsavedChanges.ShouldBeFalse();
// Verify persistence
_sut.CloseStore();
_sut.OpenStore(storePath, keyPath);
_sut.GetKeys().ShouldContain("newKey");
}
[Fact]
public void EnsureAllRequiredEntries_ReturnsEmptyList_WhenNoMissingKeys()
{
// Arrange
var storePath = Path.Combine(_testDirectory, "test.json");
var keyPath = Path.Combine(_testDirectory, "test.key");
_sut.CreateStore(storePath, keyPath);
_sut.SetSecret("key1", "value1");
_sut.SetSecret("key2", "value2");
_sut.Save();
// Act
var addedKeys = _sut.EnsureAllRequiredEntries(
new[] { "key1" },
new[] { "key2" });
// Assert
addedKeys.ShouldBeEmpty();
}
[Fact]
public void EnsureAllRequiredEntries_WhenNoStoreOpen_ThrowsInvalidOperationException()
{
// Act & Assert
Should.Throw<InvalidOperationException>(() => _sut.EnsureAllRequiredEntries(
new[] { "key" },
Array.Empty<string>()));
}
[Fact]
public void EnsureAllRequiredEntries_HandlesDuplicatesAcrossLists()
{
// Arrange
var storePath = Path.Combine(_testDirectory, "test.json");
var keyPath = Path.Combine(_testDirectory, "test.key");
_sut.CreateStore(storePath, keyPath);
// Act - same key in both lists should not cause issues
var addedKeys = _sut.EnsureAllRequiredEntries(
new[] { "sharedKey" },
new[] { "sharedKey" });
// Assert - should only add once
addedKeys.Count.ShouldBe(1);
addedKeys.ShouldContain("sharedKey");
}
}
@@ -0,0 +1,206 @@
using JdeScoping.ConfigManager.Core.Models;
using JdeScoping.ConfigManager.Core.Services;
using JdeScoping.DataSync.Configuration;
namespace JdeScoping.ConfigManager.Core.Tests.Services;
public class ValidationServiceTests
{
private readonly ValidationService _sut;
public ValidationServiceTests()
{
_sut = new ValidationService();
}
[Fact]
public void ValidateAppSettings_WithValidConfig_ReturnsNoErrors()
{
// Arrange
var config = new ConfigModel
{
DataSync = new DataSyncSection { MaxDegreeOfParallelism = 4 }
};
// Act
var result = _sut.ValidateAppSettings(config);
// Assert
result.IsValid.ShouldBeTrue();
result.Errors.ShouldBeEmpty();
}
[Fact]
public void ValidateAppSettings_WithInvalidParallelism_ReturnsError()
{
// Arrange
var config = new ConfigModel
{
DataSync = new DataSyncSection { MaxDegreeOfParallelism = 0 }
};
// Act
var result = _sut.ValidateAppSettings(config);
// Assert
result.IsValid.ShouldBeFalse();
result.Errors.ShouldContain(e => e.Contains("MaxDegreeOfParallelism"));
}
[Fact]
public void ValidatePipelines_WithEmptyName_ReturnsError()
{
// Arrange
var pipelines = new Dictionary<string, EtlPipelineConfig>
{
[""] = new EtlPipelineConfig()
};
// Act
var result = _sut.ValidatePipelines(pipelines);
// Assert
result.IsValid.ShouldBeFalse();
}
[Fact]
public void ValidatePipelines_WithInvalidConnection_ReturnsError()
{
// Arrange
var pipelines = new Dictionary<string, EtlPipelineConfig>
{
["Test"] = new EtlPipelineConfig
{
Source = new SourceElement { Connection = "invalid" }
}
};
// Act
var result = _sut.ValidatePipelines(pipelines);
// Assert
result.IsValid.ShouldBeFalse();
result.Errors.ShouldContain(e => e.Contains("Connection"));
}
[Fact]
public void ValidatePipeline_WithValidConfig_ReturnsNoErrors()
{
// Arrange
var pipeline = new EtlPipelineConfig
{
Name = "Test",
IsEnabled = true,
Source = new SourceElement
{
Connection = "jde",
Query = "SELECT * FROM Test"
},
Destination = new DestinationElement
{
Table = "TestTable",
MatchColumns = ["Id"]
},
HourlySyncIntervalMinutes = 60
};
// Act
var result = _sut.ValidatePipeline(pipeline, "Test");
// Assert
result.IsValid.ShouldBeTrue();
result.Errors.ShouldBeEmpty();
}
[Fact]
public void ValidatePipeline_WithMissingQuery_ReturnsError()
{
// Arrange
var pipeline = new EtlPipelineConfig
{
Source = new SourceElement { Connection = "jde", Query = "" },
Destination = new DestinationElement { Table = "TestTable" },
IsManualOnly = true
};
// Act
var result = _sut.ValidatePipeline(pipeline, "Test");
// Assert
result.IsValid.ShouldBeFalse();
result.Errors.ShouldContain(e => e.Contains("Query"));
}
[Fact]
public void ValidatePipeline_WithNoScheduleAndNotManual_ReturnsWarning()
{
// Arrange
var pipeline = new EtlPipelineConfig
{
Source = new SourceElement { Connection = "jde", Query = "SELECT 1" },
Destination = new DestinationElement { Table = "TestTable", MatchColumns = ["Id"] },
IsManualOnly = false // No schedules and not manual-only
};
// Act
var result = _sut.ValidatePipeline(pipeline, "Test");
// Assert
result.Warnings.ShouldContain(w => w.Contains("No sync schedule"));
}
[Fact]
public void ValidatePipeline_ManualOnly_DoesNotRequireSchedule()
{
// Arrange
var pipeline = new EtlPipelineConfig
{
Source = new SourceElement { Connection = "jde", Query = "SELECT 1" },
Destination = new DestinationElement { Table = "TestTable", MatchColumns = ["Id"] },
IsManualOnly = true
};
// Act
var result = _sut.ValidatePipeline(pipeline, "Test");
// Assert
result.Warnings.ShouldNotContain(w => w.Contains("No sync schedule"));
}
[Fact]
public void ValidatePipeline_WithIntervalBelowMinimum_ReturnsError()
{
// Arrange
var pipeline = new EtlPipelineConfig
{
Source = new SourceElement { Connection = "jde", Query = "SELECT 1" },
Destination = new DestinationElement { Table = "TestTable" },
HourlySyncIntervalMinutes = 5 // Below minimum of 15
};
// Act
var result = _sut.ValidatePipeline(pipeline, "Test");
// Assert
result.IsValid.ShouldBeFalse();
result.Errors.ShouldContain(e => e.Contains("Hourly sync interval"));
}
[Fact]
public void ValidatePipeline_WithNoMatchColumns_ReturnsWarning()
{
// Arrange
var pipeline = new EtlPipelineConfig
{
Source = new SourceElement { Connection = "jde", Query = "SELECT 1" },
Destination = new DestinationElement { Table = "TestTable", MatchColumns = [] },
IsManualOnly = true
};
// Act
var result = _sut.ValidatePipeline(pipeline, "Test");
// Assert
result.Warnings.ShouldContain(w => w.Contains("No MatchColumns"));
}
}