From 2280f12eab437908b8f875ca7b23f6c1996f0e60 Mon Sep 17 00:00:00 2001 From: Dev Date: Wed, 8 Apr 2026 12:29:53 +0200 Subject: [PATCH] feat(10-01): create logo models, BrandingRepository, and repository tests - Add LogoData record with Base64 and MimeType init properties - Add BrandingSettings class with nullable MspLogo property - Extend TenantProfile with nullable ClientLogo property (additive) - Add BrandingRepository mirroring SettingsRepository pattern (write-then-replace) - Add BrandingRepositoryTests: 5 tests covering load defaults, round-trip, dir creation, and TenantProfile serialization --- .../Services/BrandingRepositoryTests.cs | 130 ++++++++++++++++++ .../Core/Models/BrandingSettings.cs | 6 + SharepointToolbox/Core/Models/LogoData.cs | 7 + .../Core/Models/TenantProfile.cs | 1 + .../Persistence/BrandingRepository.cs | 74 ++++++++++ 5 files changed, 218 insertions(+) create mode 100644 SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs create mode 100644 SharepointToolbox/Core/Models/BrandingSettings.cs create mode 100644 SharepointToolbox/Core/Models/LogoData.cs create mode 100644 SharepointToolbox/Infrastructure/Persistence/BrandingRepository.cs diff --git a/SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs b/SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs new file mode 100644 index 0000000..fbe0142 --- /dev/null +++ b/SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs @@ -0,0 +1,130 @@ +using System.IO; +using System.Text; +using System.Text.Json; +using SharepointToolbox.Core.Models; +using SharepointToolbox.Infrastructure.Persistence; + +namespace SharepointToolbox.Tests.Services; + +[Trait("Category", "Unit")] +public class BrandingRepositoryTests : IDisposable +{ + private readonly string _tempFile; + + public BrandingRepositoryTests() + { + _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 BrandingRepository CreateRepository() => new(_tempFile); + + [Fact] + public async Task LoadAsync_MissingFile_ReturnsDefaultBrandingSettings() + { + var repo = CreateRepository(); + + var settings = await repo.LoadAsync(); + + Assert.Null(settings.MspLogo); + } + + [Fact] + public async Task SaveAndLoad_RoundTrips_MspLogo() + { + var repo = CreateRepository(); + var logo = new LogoData { Base64 = "abc123==", MimeType = "image/png" }; + var original = new BrandingSettings { MspLogo = logo }; + + await repo.SaveAsync(original); + var loaded = await repo.LoadAsync(); + + Assert.NotNull(loaded.MspLogo); + Assert.Equal("abc123==", loaded.MspLogo.Base64); + Assert.Equal("image/png", loaded.MspLogo.MimeType); + } + + [Fact] + public async Task SaveAsync_CreatesDirectoryIfNotExists() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName(), "subdir"); + var filePath = Path.Combine(tempDir, "branding.json"); + var repo = new BrandingRepository(filePath); + + try + { + await repo.SaveAsync(new BrandingSettings()); + Assert.True(File.Exists(filePath), "File must be created even when directory did not exist"); + } + finally + { + if (File.Exists(filePath)) File.Delete(filePath); + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task TenantProfile_WithClientLogo_SerializesAndDeserializesCorrectly() + { + var logo = new LogoData { Base64 = "xyz==", MimeType = "image/jpeg" }; + var profile = new TenantProfile + { + Name = "Contoso", + TenantUrl = "https://contoso.sharepoint.com", + ClientId = "client-id-123", + ClientLogo = logo + }; + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + var json = JsonSerializer.Serialize(profile, options); + + // Verify camelCase key exists + using var doc = JsonDocument.Parse(json); + Assert.True(doc.RootElement.TryGetProperty("clientLogo", out var clientLogoElem), + "JSON must contain 'clientLogo' key (camelCase)"); + Assert.Equal(JsonValueKind.Object, clientLogoElem.ValueKind); + + // Deserialize back + var readOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var loaded = JsonSerializer.Deserialize(json, readOptions); + + Assert.NotNull(loaded?.ClientLogo); + Assert.Equal("xyz==", loaded.ClientLogo.Base64); + Assert.Equal("image/jpeg", loaded.ClientLogo.MimeType); + } + + [Fact] + public async Task TenantProfile_WithoutClientLogo_SerializesWithNullAndDeserializesWithNull() + { + var profile = new TenantProfile + { + Name = "Fabrikam", + TenantUrl = "https://fabrikam.sharepoint.com", + ClientId = "client-id-456" + }; + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + var json = JsonSerializer.Serialize(profile, options); + + // Deserialize back — ClientLogo should be null (forward compatible) + var readOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var loaded = JsonSerializer.Deserialize(json, readOptions); + + Assert.NotNull(loaded); + Assert.Null(loaded.ClientLogo); + } +} diff --git a/SharepointToolbox/Core/Models/BrandingSettings.cs b/SharepointToolbox/Core/Models/BrandingSettings.cs new file mode 100644 index 0000000..89ff15a --- /dev/null +++ b/SharepointToolbox/Core/Models/BrandingSettings.cs @@ -0,0 +1,6 @@ +namespace SharepointToolbox.Core.Models; + +public class BrandingSettings +{ + public LogoData? MspLogo { get; set; } +} diff --git a/SharepointToolbox/Core/Models/LogoData.cs b/SharepointToolbox/Core/Models/LogoData.cs new file mode 100644 index 0000000..fb7dda9 --- /dev/null +++ b/SharepointToolbox/Core/Models/LogoData.cs @@ -0,0 +1,7 @@ +namespace SharepointToolbox.Core.Models; + +public record LogoData +{ + public string Base64 { get; init; } = string.Empty; + public string MimeType { get; init; } = string.Empty; +} diff --git a/SharepointToolbox/Core/Models/TenantProfile.cs b/SharepointToolbox/Core/Models/TenantProfile.cs index eac90a1..f20663f 100644 --- a/SharepointToolbox/Core/Models/TenantProfile.cs +++ b/SharepointToolbox/Core/Models/TenantProfile.cs @@ -5,4 +5,5 @@ public class TenantProfile public string Name { get; set; } = string.Empty; public string TenantUrl { get; set; } = string.Empty; public string ClientId { get; set; } = string.Empty; + public LogoData? ClientLogo { get; set; } } diff --git a/SharepointToolbox/Infrastructure/Persistence/BrandingRepository.cs b/SharepointToolbox/Infrastructure/Persistence/BrandingRepository.cs new file mode 100644 index 0000000..a975c2f --- /dev/null +++ b/SharepointToolbox/Infrastructure/Persistence/BrandingRepository.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 BrandingRepository +{ + private readonly string _filePath; + private readonly SemaphoreSlim _writeLock = new(1, 1); + + public BrandingRepository(string filePath) + { + _filePath = filePath; + } + + public async Task LoadAsync() + { + if (!File.Exists(_filePath)) + return new BrandingSettings(); + + string json; + try + { + json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8); + } + catch (IOException ex) + { + throw new InvalidDataException($"Failed to read branding file: {_filePath}", ex); + } + + try + { + var settings = JsonSerializer.Deserialize(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return settings ?? new BrandingSettings(); + } + catch (JsonException ex) + { + throw new InvalidDataException($"Branding file contains invalid JSON: {_filePath}", ex); + } + } + + public async Task SaveAsync(BrandingSettings 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(); + } + } +}