From 54620ccb2e185644a524bcaec5bc9184161bea9d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Jan 2026 17:38:20 -0500 Subject: [PATCH] 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 --- .../Services/ConfigFileService.cs | 75 +++++++++++++++++++ .../Services/ConfigLoadException.cs | 15 ++++ .../Services/IConfigFileService.cs | 14 ++++ .../Services/ConfigFileServiceTests.cs | 54 +++++++++++++ 4 files changed, 158 insertions(+) create mode 100644 NEW/src/Utils/JdeScoping.ConfigManager/Services/ConfigFileService.cs create mode 100644 NEW/src/Utils/JdeScoping.ConfigManager/Services/ConfigLoadException.cs create mode 100644 NEW/src/Utils/JdeScoping.ConfigManager/Services/IConfigFileService.cs create mode 100644 NEW/tests/JdeScoping.ConfigManager.Tests/Services/ConfigFileServiceTests.cs diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/ConfigFileService.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/ConfigFileService.cs new file mode 100644 index 0000000..a11c952 --- /dev/null +++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/ConfigFileService.cs @@ -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; + +/// +/// Service for loading and saving configuration files. +/// +public class ConfigFileService : IConfigFileService +{ + private readonly IFileSystem _fileSystem; + private readonly ILogger? _logger; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNameCaseInsensitive = true + }; + + public ConfigFileService(IFileSystem fileSystem, ILogger? logger = null) + { + _fileSystem = fileSystem; + _logger = logger; + } + + public async Task 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(json, JsonOptions); + return config ?? new ConfigModel(); + } + catch (JsonException ex) + { + throw new ConfigLoadException(path, $"Failed to parse appsettings.json: {ex.Message}", ex); + } + } + + public async Task 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(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); + } +} diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/ConfigLoadException.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/ConfigLoadException.cs new file mode 100644 index 0000000..12a78de --- /dev/null +++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/ConfigLoadException.cs @@ -0,0 +1,15 @@ +namespace JdeScoping.ConfigManager.Services; + +/// +/// Exception thrown when configuration file loading fails. +/// +public class ConfigLoadException : Exception +{ + public string FilePath { get; } + + public ConfigLoadException(string filePath, string message, Exception? inner = null) + : base(message, inner) + { + FilePath = filePath; + } +} diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/IConfigFileService.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/IConfigFileService.cs new file mode 100644 index 0000000..c532253 --- /dev/null +++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/IConfigFileService.cs @@ -0,0 +1,14 @@ +using JdeScoping.ConfigManager.Models; + +namespace JdeScoping.ConfigManager.Services; + +/// +/// Service for loading and saving configuration files. +/// +public interface IConfigFileService +{ + Task LoadAppSettingsAsync(string path, CancellationToken ct = default); + Task LoadPipelinesAsync(string path, CancellationToken ct = default); + Task SaveAppSettingsAsync(string path, ConfigModel config, CancellationToken ct = default); + Task SavePipelinesAsync(string path, PipelinesConfigModel config, CancellationToken ct = default); +} diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/Services/ConfigFileServiceTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/Services/ConfigFileServiceTests.cs new file mode 100644 index 0000000..351de8f --- /dev/null +++ b/NEW/tests/JdeScoping.ConfigManager.Tests/Services/ConfigFileServiceTests.cs @@ -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(); + _sut = new ConfigFileService(_fileSystem); + } + + [Fact] + public async Task LoadAppSettingsAsync_WithValidJson_ReturnsConfigModel() + { + // Arrange + var json = """ + { + "DataSync": { + "Enabled": true, + "MaxDegreeOfParallelism": 8 + } + } + """; + _fileSystem.ReadAllTextAsync(Arg.Any(), Arg.Any()) + .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(), Arg.Any()) + .Returns(Task.FromResult(json)); + + // Act & Assert + var ex = await Should.ThrowAsync( + () => _sut.LoadAppSettingsAsync("/config/appsettings.json")); + ex.Message.ShouldContain("parse"); + } +}