diff --git a/SharepointToolbox.Tests/Services/SettingsServiceTests.cs b/SharepointToolbox.Tests/Services/SettingsServiceTests.cs index 14a9ad3..99ceb09 100644 --- a/SharepointToolbox.Tests/Services/SettingsServiceTests.cs +++ b/SharepointToolbox.Tests/Services/SettingsServiceTests.cs @@ -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(() => service.SetLanguageAsync("de")); + } } diff --git a/SharepointToolbox/Core/Models/AppSettings.cs b/SharepointToolbox/Core/Models/AppSettings.cs new file mode 100644 index 0000000..f0538a0 --- /dev/null +++ b/SharepointToolbox/Core/Models/AppSettings.cs @@ -0,0 +1,7 @@ +namespace SharepointToolbox.Core.Models; + +public class AppSettings +{ + public string DataFolder { get; set; } = string.Empty; + public string Lang { get; set; } = "en"; +} diff --git a/SharepointToolbox/Infrastructure/Persistence/SettingsRepository.cs b/SharepointToolbox/Infrastructure/Persistence/SettingsRepository.cs new file mode 100644 index 0000000..f038c28 --- /dev/null +++ b/SharepointToolbox/Infrastructure/Persistence/SettingsRepository.cs @@ -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 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(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(); + } + } +} diff --git a/SharepointToolbox/Services/SettingsService.cs b/SharepointToolbox/Services/SettingsService.cs new file mode 100644 index 0000000..5353d89 --- /dev/null +++ b/SharepointToolbox/Services/SettingsService.cs @@ -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 SupportedLanguages = new(StringComparer.OrdinalIgnoreCase) + { + "en", "fr" + }; + + public SettingsService(SettingsRepository repository) + { + _repository = repository; + } + + public Task 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); + } +}