feat(01-03): SettingsRepository and SettingsService with write-then-replace

- AppSettings model: DataFolder + Lang with camelCase JSON serialization
- SettingsRepository: SemaphoreSlim write lock + write-then-replace (tmp→validate→move)
- SettingsService: GetSettings/SetLanguage/SetDataFolder; SetLanguage validates en/fr only
- All 8 SettingsServiceTests pass; all 18 Unit tests pass
This commit is contained in:
Dev
2026-04-02 12:12:02 +02:00
parent 769196dabe
commit ac3fa5c8eb
4 changed files with 239 additions and 3 deletions

View File

@@ -1,7 +1,123 @@
using System.IO;
using System.Text;
using System.Text.Json;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Infrastructure.Persistence;
using SharepointToolbox.Services;
namespace SharepointToolbox.Tests.Services;
public class SettingsServiceTests
[Trait("Category", "Unit")]
public class SettingsServiceTests : IDisposable
{
[Fact(Skip = "Wave 0 stub — implemented in plan 01-03")]
public void SaveAndLoad_RoundTrips_Settings() { }
private readonly string _tempFile;
public SettingsServiceTests()
{
_tempFile = Path.GetTempFileName();
File.Delete(_tempFile);
}
public void Dispose()
{
if (File.Exists(_tempFile)) File.Delete(_tempFile);
if (File.Exists(_tempFile + ".tmp")) File.Delete(_tempFile + ".tmp");
}
private SettingsRepository CreateRepository() => new(_tempFile);
private SettingsService CreateService() => new(CreateRepository());
[Fact]
public async Task LoadAsync_MissingFile_ReturnsDefaultSettings()
{
var repo = CreateRepository();
var settings = await repo.LoadAsync();
Assert.Equal(string.Empty, settings.DataFolder);
Assert.Equal("en", settings.Lang);
}
[Fact]
public async Task SaveAndLoad_RoundTrips_DataFolderAndLang()
{
var repo = CreateRepository();
var original = new AppSettings { DataFolder = @"C:\Exports", Lang = "fr" };
await repo.SaveAsync(original);
var loaded = await repo.LoadAsync();
Assert.Equal(@"C:\Exports", loaded.DataFolder);
Assert.Equal("fr", loaded.Lang);
}
[Fact]
public async Task SaveAsync_SerializedJson_UsesDataFolderAndLangKeys()
{
var repo = CreateRepository();
await repo.SaveAsync(new AppSettings { DataFolder = @"C:\Exports", Lang = "fr" });
var json = await File.ReadAllTextAsync(_tempFile);
using var doc = JsonDocument.Parse(json);
Assert.True(doc.RootElement.TryGetProperty("dataFolder", out _),
"JSON must contain 'dataFolder' key (camelCase for schema compatibility)");
Assert.True(doc.RootElement.TryGetProperty("lang", out _),
"JSON must contain 'lang' key (camelCase for schema compatibility)");
}
[Fact]
public async Task SaveAsync_UsesTmpFileThenMove()
{
var repo = CreateRepository();
// The .tmp file should not exist after a successful save
await repo.SaveAsync(new AppSettings { DataFolder = @"C:\Test", Lang = "en" });
Assert.False(File.Exists(_tempFile + ".tmp"),
"Temp file should have been moved/deleted after successful save");
Assert.True(File.Exists(_tempFile), "Settings file must exist after save");
}
[Fact]
public async Task SetLanguageAsync_PersistsLang()
{
var service = CreateService();
await service.SetLanguageAsync("fr");
var settings = await service.GetSettingsAsync();
Assert.Equal("fr", settings.Lang);
}
[Fact]
public async Task SetDataFolderAsync_PersistsPath()
{
var service = CreateService();
await service.SetDataFolderAsync(@"C:\Exports");
var settings = await service.GetSettingsAsync();
Assert.Equal(@"C:\Exports", settings.DataFolder);
}
[Fact]
public async Task SetDataFolderAsync_EmptyString_IsAllowed()
{
var service = CreateService();
await service.SetDataFolderAsync(@"C:\Exports");
await service.SetDataFolderAsync(string.Empty);
var settings = await service.GetSettingsAsync();
Assert.Equal(string.Empty, settings.DataFolder);
}
[Fact]
public async Task SetLanguageAsync_InvalidCode_ThrowsArgumentException()
{
var service = CreateService();
await Assert.ThrowsAsync<ArgumentException>(() => service.SetLanguageAsync("de"));
}
}

View File

@@ -0,0 +1,7 @@
namespace SharepointToolbox.Core.Models;
public class AppSettings
{
public string DataFolder { get; set; } = string.Empty;
public string Lang { get; set; } = "en";
}

View File

@@ -0,0 +1,74 @@
using System.IO;
using System.Text;
using System.Text.Json;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Infrastructure.Persistence;
public class SettingsRepository
{
private readonly string _filePath;
private readonly SemaphoreSlim _writeLock = new(1, 1);
public SettingsRepository(string filePath)
{
_filePath = filePath;
}
public async Task<AppSettings> LoadAsync()
{
if (!File.Exists(_filePath))
return new AppSettings();
string json;
try
{
json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8);
}
catch (IOException ex)
{
throw new InvalidDataException($"Failed to read settings file: {_filePath}", ex);
}
try
{
var settings = JsonSerializer.Deserialize<AppSettings>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return settings ?? new AppSettings();
}
catch (JsonException ex)
{
throw new InvalidDataException($"Settings file contains invalid JSON: {_filePath}", ex);
}
}
public async Task SaveAsync(AppSettings settings)
{
await _writeLock.WaitAsync();
try
{
var json = JsonSerializer.Serialize(settings,
new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
var tmpPath = _filePath + ".tmp";
var dir = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8);
// Validate round-trip before replacing
JsonDocument.Parse(await File.ReadAllTextAsync(tmpPath, Encoding.UTF8)).Dispose();
File.Move(tmpPath, _filePath, overwrite: true);
}
finally
{
_writeLock.Release();
}
}
}

View File

@@ -0,0 +1,39 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Infrastructure.Persistence;
namespace SharepointToolbox.Services;
public class SettingsService
{
private readonly SettingsRepository _repository;
private static readonly HashSet<string> SupportedLanguages = new(StringComparer.OrdinalIgnoreCase)
{
"en", "fr"
};
public SettingsService(SettingsRepository repository)
{
_repository = repository;
}
public Task<AppSettings> GetSettingsAsync()
=> _repository.LoadAsync();
public async Task SetLanguageAsync(string cultureCode)
{
if (!SupportedLanguages.Contains(cultureCode))
throw new ArgumentException($"Unsupported language code '{cultureCode}'. Supported: en, fr.", nameof(cultureCode));
var settings = await _repository.LoadAsync();
settings.Lang = cultureCode;
await _repository.SaveAsync(settings);
}
public async Task SetDataFolderAsync(string path)
{
var settings = await _repository.LoadAsync();
settings.DataFolder = path;
await _repository.SaveAsync(settings);
}
}