--- phase: 01-foundation plan: 03 type: execute wave: 3 depends_on: - 01-01 - 01-02 files_modified: - SharepointToolbox/Infrastructure/Persistence/ProfileRepository.cs - SharepointToolbox/Infrastructure/Persistence/SettingsRepository.cs - SharepointToolbox/Services/ProfileService.cs - SharepointToolbox/Services/SettingsService.cs - SharepointToolbox.Tests/Services/ProfileServiceTests.cs - SharepointToolbox.Tests/Services/SettingsServiceTests.cs autonomous: true requirements: - FOUND-02 - FOUND-10 - FOUND-12 must_haves: truths: - "ProfileService reads Sharepoint_Export_profiles.json without migration — field names are the contract" - "SettingsService reads Sharepoint_Settings.json preserving dataFolder and lang fields" - "Write operations use write-then-replace (file.tmp → validate → File.Move) with SemaphoreSlim(1)" - "ProfileService unit tests: SaveAndLoad round-trips, corrupt file recovery, concurrent write safety" - "SettingsService unit tests: SaveAndLoad round-trips, default settings when file missing" artifacts: - path: "SharepointToolbox/Infrastructure/Persistence/ProfileRepository.cs" provides: "File I/O for profiles JSON with write-then-replace" contains: "SemaphoreSlim" - path: "SharepointToolbox/Services/ProfileService.cs" provides: "CRUD operations on TenantProfile collection" exports: ["AddProfile", "RenameProfile", "DeleteProfile", "GetProfiles"] - path: "SharepointToolbox/Services/SettingsService.cs" provides: "Read/write for app settings including data folder and language" exports: ["GetSettings", "SaveSettings"] - path: "SharepointToolbox.Tests/Services/ProfileServiceTests.cs" provides: "Unit tests covering FOUND-02 and FOUND-10" key_links: - from: "SharepointToolbox/Infrastructure/Persistence/ProfileRepository.cs" to: "Sharepoint_Export_profiles.json" via: "System.Text.Json deserialization of { profiles: [...] } wrapper" pattern: "profiles" - from: "SharepointToolbox/Infrastructure/Persistence/SettingsRepository.cs" to: "Sharepoint_Settings.json" via: "System.Text.Json deserialization of { dataFolder, lang }" pattern: "dataFolder" --- Build the persistence layer: ProfileRepository and SettingsRepository (Infrastructure) plus ProfileService and SettingsService (Services layer). Implement write-then-replace safety. Write unit tests that validate the round-trip and edge cases. Purpose: Profiles and settings are the first user-visible data. Corrupt files or wrong field names would break existing users' data on migration. Unit tests lock in the JSON schema contract. Output: 4 production files + 2 test files with passing unit tests. @C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md @C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/phases/01-foundation/01-CONTEXT.md @.planning/phases/01-foundation/01-RESEARCH.md @.planning/phases/01-foundation/01-02-SUMMARY.md ```csharp namespace SharepointToolbox.Core.Models; public class TenantProfile { public string Name { get; set; } = string.Empty; public string TenantUrl { get; set; } = string.Empty; public string ClientId { get; set; } = string.Empty; } ``` // Sharepoint_Export_profiles.json { "profiles": [{ "name": "...", "tenantUrl": "...", "clientId": "..." }] } // Sharepoint_Settings.json { "dataFolder": "...", "lang": "en" } Task 1: ProfileRepository and ProfileService with write-then-replace SharepointToolbox/Infrastructure/Persistence/ProfileRepository.cs, SharepointToolbox/Services/ProfileService.cs, SharepointToolbox.Tests/Services/ProfileServiceTests.cs - Test: SaveAsync then LoadAsync round-trips a list of TenantProfiles with correct field values - Test: LoadAsync on missing file returns empty list (no exception) - Test: LoadAsync on corrupt JSON throws InvalidDataException (not silently returns empty) - Test: Concurrent SaveAsync calls don't corrupt the file (SemaphoreSlim ensures ordering) - Test: ProfileService.AddProfile assigns the new profile and persists immediately - Test: ProfileService.RenameProfile changes Name, persists, throws if profile not found - Test: ProfileService.DeleteProfile removes by Name, throws if not found - Test: Saved JSON wraps profiles in { "profiles": [...] } root object (schema compatibility) Create `Infrastructure/Persistence/` and `Services/` directories. **ProfileRepository.cs** — handles raw file I/O: ```csharp namespace SharepointToolbox.Infrastructure.Persistence; public class ProfileRepository { private readonly string _filePath; private readonly SemaphoreSlim _writeLock = new(1, 1); public ProfileRepository(string filePath) { _filePath = filePath; } public async Task> LoadAsync() { if (!File.Exists(_filePath)) return Array.Empty(); var json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8); var root = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); return root?.Profiles ?? Array.Empty(); } public async Task SaveAsync(IReadOnlyList profiles) { await _writeLock.WaitAsync(); try { var root = new ProfilesRoot { Profiles = profiles.ToList() }; var json = JsonSerializer.Serialize(root, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); var tmpPath = _filePath + ".tmp"; Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!); await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8); // Validate round-trip before replacing JsonDocument.Parse(await File.ReadAllTextAsync(tmpPath)).Dispose(); File.Move(tmpPath, _filePath, overwrite: true); } finally { _writeLock.Release(); } } private sealed class ProfilesRoot { public List Profiles { get; set; } = new(); } } ``` Note: Use `PropertyNamingPolicy = JsonNamingPolicy.CamelCase` to serialize `Name`→`name`, `TenantUrl`→`tenantUrl`, `ClientId`→`clientId` matching the existing JSON schema. **ProfileService.cs** — CRUD on top of repository: - Constructor takes `ProfileRepository` (inject via DI later; for now accept in constructor) - `Task> GetProfilesAsync()` - `Task AddProfileAsync(TenantProfile profile)` — validates Name not empty, TenantUrl valid URL, ClientId not empty; throws `ArgumentException` for invalid inputs - `Task RenameProfileAsync(string existingName, string newName)` — throws `KeyNotFoundException` if not found - `Task DeleteProfileAsync(string name)` — throws `KeyNotFoundException` if not found - All mutations load → modify in-memory list → save (single-load-modify-save to preserve order) **ProfileServiceTests.cs** — Replace the stub with real tests using temp file paths: ```csharp public class ProfileServiceTests : IDisposable { private readonly string _tempFile = Path.GetTempFileName(); // Dispose deletes temp file [Fact] public async Task SaveAndLoad_RoundTrips_Profiles() { ... } // etc. } ``` Tests must use a temp file, not the real user data file. All tests in `[Trait("Category", "Unit")]`. cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~ProfileServiceTests" 2>&1 | tail -10 All ProfileServiceTests pass (no skips). JSON output uses camelCase field names matching existing schema. Write-then-replace with SemaphoreSlim implemented. Task 2: SettingsRepository and SettingsService SharepointToolbox/Infrastructure/Persistence/SettingsRepository.cs, SharepointToolbox/Services/SettingsService.cs, SharepointToolbox.Tests/Services/SettingsServiceTests.cs - Test: LoadAsync returns default settings (dataFolder = empty string, lang = "en") when file missing - Test: SaveAsync then LoadAsync round-trips dataFolder and lang values exactly - Test: Serialized JSON contains "dataFolder" and "lang" keys (not DataFolder/Lang — schema compatibility) - Test: SaveAsync uses write-then-replace (tmp file created, then moved) - Test: SettingsService.SetLanguageAsync("fr") persists lang="fr" - Test: SettingsService.SetDataFolderAsync("C:\\Exports") persists dataFolder path **AppSettings model** (add to `Core/Models/AppSettings.cs`): ```csharp namespace SharepointToolbox.Core.Models; public class AppSettings { public string DataFolder { get; set; } = string.Empty; public string Lang { get; set; } = "en"; } ``` Note: STJ with `PropertyNamingPolicy.CamelCase` will serialize `DataFolder`→`dataFolder`, `Lang`→`lang`. **SettingsRepository.cs** — same write-then-replace pattern as ProfileRepository: - `Task LoadAsync()` — returns `new AppSettings()` if file missing; throws `InvalidDataException` on corrupt JSON - `Task SaveAsync(AppSettings settings)` — write-then-replace with `SemaphoreSlim(1)` and camelCase serialization **SettingsService.cs**: - Constructor takes `SettingsRepository` - `Task GetSettingsAsync()` - `Task SetLanguageAsync(string cultureCode)` — validates "en" or "fr"; throws `ArgumentException` otherwise - `Task SetDataFolderAsync(string path)` — saves path (empty string allowed — means default) **SettingsServiceTests.cs** — Replace stub with real tests using temp file. All tests in `[Trait("Category", "Unit")]`. cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~SettingsServiceTests" 2>&1 | tail -10 All SettingsServiceTests pass. AppSettings serializes to dataFolder/lang (camelCase). Default settings returned when file is absent. - `dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "Category=Unit"` — all pass - JSON output from ProfileRepository contains `"profiles"` root key with `"name"`, `"tenantUrl"`, `"clientId"` field names - JSON output from SettingsRepository contains `"dataFolder"` and `"lang"` field names - Both repositories use `SemaphoreSlim(1)` write lock - Both repositories use write-then-replace (`.tmp` file then `File.Move`) Unit tests green for ProfileService and SettingsService. JSON schema compatibility verified by test assertions on serialized output. Write-then-replace pattern protects against crash-corruption. After completion, create `.planning/phases/01-foundation/01-03-SUMMARY.md`