Archive 5 phases (36 plans) to milestones/v1.0-phases/. Archive roadmap, requirements, and audit to milestones/. Evolve PROJECT.md with shipped state and validated requirements. Collapse ROADMAP.md to one-line milestone summary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
255 lines
12 KiB
Markdown
255 lines
12 KiB
Markdown
---
|
|
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"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
|
|
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.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
|
|
|
|
<interfaces>
|
|
<!-- From Core/Models/TenantProfile.cs (plan 01-02) -->
|
|
```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;
|
|
}
|
|
```
|
|
|
|
<!-- JSON schema contracts (live user data — field names are frozen) -->
|
|
// Sharepoint_Export_profiles.json
|
|
{ "profiles": [{ "name": "...", "tenantUrl": "...", "clientId": "..." }] }
|
|
|
|
// Sharepoint_Settings.json
|
|
{ "dataFolder": "...", "lang": "en" }
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: ProfileRepository and ProfileService with write-then-replace</name>
|
|
<files>
|
|
SharepointToolbox/Infrastructure/Persistence/ProfileRepository.cs,
|
|
SharepointToolbox/Services/ProfileService.cs,
|
|
SharepointToolbox.Tests/Services/ProfileServiceTests.cs
|
|
</files>
|
|
<behavior>
|
|
- 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)
|
|
</behavior>
|
|
<action>
|
|
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<IReadOnlyList<TenantProfile>> LoadAsync()
|
|
{
|
|
if (!File.Exists(_filePath))
|
|
return Array.Empty<TenantProfile>();
|
|
|
|
var json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8);
|
|
var root = JsonSerializer.Deserialize<ProfilesRoot>(json,
|
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
|
return root?.Profiles ?? Array.Empty<TenantProfile>();
|
|
}
|
|
|
|
public async Task SaveAsync(IReadOnlyList<TenantProfile> 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<TenantProfile> 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<IReadOnlyList<TenantProfile>> 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")]`.
|
|
</action>
|
|
<verify>
|
|
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~ProfileServiceTests" 2>&1 | tail -10</automated>
|
|
</verify>
|
|
<done>All ProfileServiceTests pass (no skips). JSON output uses camelCase field names matching existing schema. Write-then-replace with SemaphoreSlim implemented.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: SettingsRepository and SettingsService</name>
|
|
<files>
|
|
SharepointToolbox/Infrastructure/Persistence/SettingsRepository.cs,
|
|
SharepointToolbox/Services/SettingsService.cs,
|
|
SharepointToolbox.Tests/Services/SettingsServiceTests.cs
|
|
</files>
|
|
<behavior>
|
|
- 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
|
|
</behavior>
|
|
<action>
|
|
**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<AppSettings> 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<AppSettings> 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")]`.
|
|
</action>
|
|
<verify>
|
|
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~SettingsServiceTests" 2>&1 | tail -10</automated>
|
|
</verify>
|
|
<done>All SettingsServiceTests pass. AppSettings serializes to dataFolder/lang (camelCase). Default settings returned when file is absent.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `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`)
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
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.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/01-foundation/01-03-SUMMARY.md`
|
|
</output>
|