feat(configmanager): add ConfigFileService with tests

Add config file loading and saving service following TDD approach:
- IConfigFileService interface for loading/saving config files
- ConfigLoadException for descriptive error handling
- ConfigFileService implementation with JSON serialization
- Unit tests with mocked IFileSystem dependency
This commit is contained in:
Joseph Doherty
2026-01-19 17:38:20 -05:00
parent 4335286560
commit 54620ccb2e
4 changed files with 158 additions and 0 deletions
@@ -0,0 +1,75 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using JdeScoping.ConfigManager.Models;
using Microsoft.Extensions.Logging;
namespace JdeScoping.ConfigManager.Services;
/// <summary>
/// Service for loading and saving configuration files.
/// </summary>
public class ConfigFileService : IConfigFileService
{
private readonly IFileSystem _fileSystem;
private readonly ILogger<ConfigFileService>? _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNameCaseInsensitive = true
};
public ConfigFileService(IFileSystem fileSystem, ILogger<ConfigFileService>? logger = null)
{
_fileSystem = fileSystem;
_logger = logger;
}
public async Task<ConfigModel> LoadAppSettingsAsync(string path, CancellationToken ct = default)
{
_logger?.LogInformation("Loading appsettings from {Path}", path);
try
{
var json = await _fileSystem.ReadAllTextAsync(path, ct);
var config = JsonSerializer.Deserialize<ConfigModel>(json, JsonOptions);
return config ?? new ConfigModel();
}
catch (JsonException ex)
{
throw new ConfigLoadException(path, $"Failed to parse appsettings.json: {ex.Message}", ex);
}
}
public async Task<PipelinesConfigModel> LoadPipelinesAsync(string path, CancellationToken ct = default)
{
_logger?.LogInformation("Loading pipelines from {Path}", path);
try
{
var json = await _fileSystem.ReadAllTextAsync(path, ct);
var config = JsonSerializer.Deserialize<PipelinesConfigModel>(json, JsonOptions);
return config ?? new PipelinesConfigModel();
}
catch (JsonException ex)
{
throw new ConfigLoadException(path, $"Failed to parse pipelines.json: {ex.Message}", ex);
}
}
public async Task SaveAppSettingsAsync(string path, ConfigModel config, CancellationToken ct = default)
{
_logger?.LogInformation("Saving appsettings to {Path}", path);
var json = JsonSerializer.Serialize(config, JsonOptions);
await _fileSystem.WriteAllTextAsync(path, json, ct);
}
public async Task SavePipelinesAsync(string path, PipelinesConfigModel config, CancellationToken ct = default)
{
_logger?.LogInformation("Saving pipelines to {Path}", path);
var json = JsonSerializer.Serialize(config, JsonOptions);
await _fileSystem.WriteAllTextAsync(path, json, ct);
}
}
@@ -0,0 +1,15 @@
namespace JdeScoping.ConfigManager.Services;
/// <summary>
/// Exception thrown when configuration file loading fails.
/// </summary>
public class ConfigLoadException : Exception
{
public string FilePath { get; }
public ConfigLoadException(string filePath, string message, Exception? inner = null)
: base(message, inner)
{
FilePath = filePath;
}
}
@@ -0,0 +1,14 @@
using JdeScoping.ConfigManager.Models;
namespace JdeScoping.ConfigManager.Services;
/// <summary>
/// Service for loading and saving configuration files.
/// </summary>
public interface IConfigFileService
{
Task<ConfigModel> LoadAppSettingsAsync(string path, CancellationToken ct = default);
Task<PipelinesConfigModel> LoadPipelinesAsync(string path, CancellationToken ct = default);
Task SaveAppSettingsAsync(string path, ConfigModel config, CancellationToken ct = default);
Task SavePipelinesAsync(string path, PipelinesConfigModel config, CancellationToken ct = default);
}
@@ -0,0 +1,54 @@
using JdeScoping.ConfigManager.Models;
using JdeScoping.ConfigManager.Services;
namespace JdeScoping.ConfigManager.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");
}
}