diff --git a/SharepointToolbox.Tests/Services/ProfileServiceTests.cs b/SharepointToolbox.Tests/Services/ProfileServiceTests.cs index 7cbb63a..c66aac9 100644 --- a/SharepointToolbox.Tests/Services/ProfileServiceTests.cs +++ b/SharepointToolbox.Tests/Services/ProfileServiceTests.cs @@ -1,7 +1,172 @@ +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 ProfileServiceTests +[Trait("Category", "Unit")] +public class ProfileServiceTests : IDisposable { - [Fact(Skip = "Wave 0 stub — implemented in plan 01-03")] - public void SaveAndLoad_RoundTrips_Profiles() { } + private readonly string _tempFile; + + public ProfileServiceTests() + { + _tempFile = Path.GetTempFileName(); + // Ensure the file doesn't exist so tests start clean + File.Delete(_tempFile); + } + + public void Dispose() + { + if (File.Exists(_tempFile)) File.Delete(_tempFile); + if (File.Exists(_tempFile + ".tmp")) File.Delete(_tempFile + ".tmp"); + } + + private ProfileRepository CreateRepository() => new(_tempFile); + private ProfileService CreateService() => new(CreateRepository()); + + [Fact] + public async Task SaveAndLoad_RoundTrips_Profiles() + { + var repo = CreateRepository(); + var profiles = new List + { + new() { Name = "Contoso", TenantUrl = "https://contoso.sharepoint.com", ClientId = "client-id-1" }, + new() { Name = "Fabrikam", TenantUrl = "https://fabrikam.sharepoint.com", ClientId = "client-id-2" } + }; + + await repo.SaveAsync(profiles); + var loaded = await repo.LoadAsync(); + + Assert.Equal(2, loaded.Count); + Assert.Equal("Contoso", loaded[0].Name); + Assert.Equal("https://contoso.sharepoint.com", loaded[0].TenantUrl); + Assert.Equal("client-id-1", loaded[0].ClientId); + Assert.Equal("Fabrikam", loaded[1].Name); + } + + [Fact] + public async Task LoadAsync_MissingFile_ReturnsEmptyList() + { + var repo = CreateRepository(); + + var result = await repo.LoadAsync(); + + Assert.Empty(result); + } + + [Fact] + public async Task LoadAsync_CorruptJson_ThrowsInvalidDataException() + { + await File.WriteAllTextAsync(_tempFile, "{ not valid json !!!", System.Text.Encoding.UTF8); + var repo = CreateRepository(); + + await Assert.ThrowsAsync(() => repo.LoadAsync()); + } + + [Fact] + public async Task SaveAsync_ConcurrentCalls_DoNotCorruptFile() + { + var repo = CreateRepository(); + var tasks = new List(); + for (int i = 0; i < 10; i++) + { + var idx = i; + tasks.Add(Task.Run(async () => + { + var profiles = new List + { + new() { Name = $"Profile{idx}", TenantUrl = $"https://tenant{idx}.sharepoint.com", ClientId = $"cid-{idx}" } + }; + await repo.SaveAsync(profiles); + })); + } + + await Task.WhenAll(tasks); + + // After all concurrent writes, file should be valid JSON (not corrupt) + var loaded = await repo.LoadAsync(); + Assert.NotNull(loaded); + Assert.Single(loaded); // last write wins, but exactly 1 item + } + + [Fact] + public async Task AddProfileAsync_PersistsNewProfile() + { + var service = CreateService(); + var profile = new TenantProfile { Name = "TestTenant", TenantUrl = "https://test.sharepoint.com", ClientId = "test-cid" }; + + await service.AddProfileAsync(profile); + + var profiles = await service.GetProfilesAsync(); + Assert.Single(profiles); + Assert.Equal("TestTenant", profiles[0].Name); + } + + [Fact] + public async Task RenameProfileAsync_ChangesName_AndPersists() + { + var service = CreateService(); + await service.AddProfileAsync(new TenantProfile { Name = "OldName", TenantUrl = "https://test.sharepoint.com", ClientId = "cid" }); + + await service.RenameProfileAsync("OldName", "NewName"); + + var profiles = await service.GetProfilesAsync(); + Assert.Single(profiles); + Assert.Equal("NewName", profiles[0].Name); + } + + [Fact] + public async Task RenameProfileAsync_ProfileNotFound_ThrowsKeyNotFoundException() + { + var service = CreateService(); + + await Assert.ThrowsAsync(() => service.RenameProfileAsync("NonExistent", "NewName")); + } + + [Fact] + public async Task DeleteProfileAsync_RemovesProfile() + { + var service = CreateService(); + await service.AddProfileAsync(new TenantProfile { Name = "ToDelete", TenantUrl = "https://test.sharepoint.com", ClientId = "cid" }); + + await service.DeleteProfileAsync("ToDelete"); + + var profiles = await service.GetProfilesAsync(); + Assert.Empty(profiles); + } + + [Fact] + public async Task DeleteProfileAsync_ProfileNotFound_ThrowsKeyNotFoundException() + { + var service = CreateService(); + + await Assert.ThrowsAsync(() => service.DeleteProfileAsync("NonExistent")); + } + + [Fact] + public async Task SaveAsync_JsonOutput_UsesProfilesRootKey() + { + var repo = CreateRepository(); + var profiles = new List + { + new() { Name = "Test", TenantUrl = "https://test.sharepoint.com", ClientId = "cid" } + }; + + await repo.SaveAsync(profiles); + + var json = await File.ReadAllTextAsync(_tempFile); + using var doc = JsonDocument.Parse(json); + Assert.True(doc.RootElement.TryGetProperty("profiles", out var profilesElement), + "Root JSON object must contain 'profiles' key (camelCase)"); + Assert.Equal(JsonValueKind.Array, profilesElement.ValueKind); + + var first = profilesElement.EnumerateArray().First(); + Assert.True(first.TryGetProperty("name", out _), "Profile must have 'name' (camelCase)"); + Assert.True(first.TryGetProperty("tenantUrl", out _), "Profile must have 'tenantUrl' (camelCase)"); + Assert.True(first.TryGetProperty("clientId", out _), "Profile must have 'clientId' (camelCase)"); + } } diff --git a/SharepointToolbox/Infrastructure/Persistence/ProfileRepository.cs b/SharepointToolbox/Infrastructure/Persistence/ProfileRepository.cs new file mode 100644 index 0000000..1264e00 --- /dev/null +++ b/SharepointToolbox/Infrastructure/Persistence/ProfileRepository.cs @@ -0,0 +1,84 @@ +using System.IO; +using System.Text; +using System.Text.Json; +using SharepointToolbox.Core.Models; + +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(); + + string json; + try + { + json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8); + } + catch (IOException ex) + { + throw new InvalidDataException($"Failed to read profiles file: {_filePath}", ex); + } + + ProfilesRoot? root; + try + { + root = JsonSerializer.Deserialize(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch (JsonException ex) + { + throw new InvalidDataException($"Profiles file contains invalid JSON: {_filePath}", ex); + } + + if (root?.Profiles is null) + return Array.Empty(); + return root.Profiles; + } + + 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"; + 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(); + } + } + + private sealed class ProfilesRoot + { + public List Profiles { get; set; } = new(); + } +} diff --git a/SharepointToolbox/Services/ProfileService.cs b/SharepointToolbox/Services/ProfileService.cs new file mode 100644 index 0000000..1d3bbe0 --- /dev/null +++ b/SharepointToolbox/Services/ProfileService.cs @@ -0,0 +1,54 @@ +using SharepointToolbox.Core.Models; +using SharepointToolbox.Infrastructure.Persistence; + +namespace SharepointToolbox.Services; + +public class ProfileService +{ + private readonly ProfileRepository _repository; + + public ProfileService(ProfileRepository repository) + { + _repository = repository; + } + + public Task> GetProfilesAsync() + => _repository.LoadAsync(); + + public async Task AddProfileAsync(TenantProfile profile) + { + if (string.IsNullOrWhiteSpace(profile.Name)) + throw new ArgumentException("Profile name must not be empty.", nameof(profile)); + + if (string.IsNullOrWhiteSpace(profile.TenantUrl) || + !Uri.TryCreate(profile.TenantUrl, UriKind.Absolute, out _)) + throw new ArgumentException("TenantUrl must be a valid absolute URL.", nameof(profile)); + + if (string.IsNullOrWhiteSpace(profile.ClientId)) + throw new ArgumentException("ClientId must not be empty.", nameof(profile)); + + var existing = (await _repository.LoadAsync()).ToList(); + existing.Add(profile); + await _repository.SaveAsync(existing); + } + + public async Task RenameProfileAsync(string existingName, string newName) + { + var profiles = (await _repository.LoadAsync()).ToList(); + var target = profiles.FirstOrDefault(p => p.Name == existingName) + ?? throw new KeyNotFoundException($"Profile '{existingName}' not found."); + + target.Name = newName; + await _repository.SaveAsync(profiles); + } + + public async Task DeleteProfileAsync(string name) + { + var profiles = (await _repository.LoadAsync()).ToList(); + var target = profiles.FirstOrDefault(p => p.Name == name) + ?? throw new KeyNotFoundException($"Profile '{name}' not found."); + + profiles.Remove(target); + await _repository.SaveAsync(profiles); + } +}