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"));
}
}