22 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 11-html-export-branding | 04 | execute | 1 |
|
true |
|
|
Purpose: BRAND-05 requires MSP logo management from Settings; BRAND-04 requires client logo management including auto-pull from tenant's Entra branding API. All commands must be exercisable without opening any View (ViewModel-testable).
Output: SettingsViewModel has browse/clear MSP logo commands, ProfileManagementViewModel has browse/clear/auto-pull client logo commands, ProfileService has UpdateProfileAsync, BrandingService has ImportLogoFromBytesAsync. All with unit tests.
<execution_context> @C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md @C:/Users/dev/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/11-html-export-branding/11-CONTEXT.md @.planning/phases/11-html-export-branding/11-RESEARCH.md From SharepointToolbox/Services/IBrandingService.cs: ```csharp public interface IBrandingService { Task ImportLogoAsync(string filePath); Task SaveMspLogoAsync(LogoData logo); Task ClearMspLogoAsync(); Task GetMspLogoAsync(); } ```From SharepointToolbox/Services/BrandingService.cs:
public class BrandingService : IBrandingService
{
private const int MaxSizeBytes = 512 * 1024;
private static readonly byte[] PngMagic = { 0x89, 0x50, 0x4E, 0x47 };
private static readonly byte[] JpegMagic = { 0xFF, 0xD8, 0xFF };
private readonly BrandingRepository _repository;
// ImportLogoAsync reads file, validates magic bytes, compresses if >512KB
// DetectMimeType private static — validates PNG/JPG magic bytes
// CompressToLimit private static — WPF PresentationCore imaging
}
From SharepointToolbox/Services/ProfileService.cs:
public class ProfileService
{
private readonly ProfileRepository _repository;
public Task<IReadOnlyList<TenantProfile>> GetProfilesAsync();
public async Task AddProfileAsync(TenantProfile profile);
public async Task RenameProfileAsync(string existingName, string newName);
public async Task DeleteProfileAsync(string name);
// NOTE: No UpdateProfileAsync yet — must be added
}
From SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs:
public partial class SettingsViewModel : FeatureViewModelBase
{
private readonly SettingsService _settingsService;
public RelayCommand BrowseFolderCommand { get; }
public SettingsViewModel(SettingsService settingsService, ILogger<FeatureViewModelBase> logger)
// Uses OpenFolderDialog in BrowseFolder() — same pattern for logo browse
}
From SharepointToolbox/ViewModels/ProfileManagementViewModel.cs:
public partial class ProfileManagementViewModel : ObservableObject
{
private readonly ProfileService _profileService;
private readonly ILogger<ProfileManagementViewModel> _logger;
[ObservableProperty] private TenantProfile? _selectedProfile;
[ObservableProperty] private string _validationMessage = string.Empty;
public ProfileManagementViewModel(ProfileService profileService, ILogger<ProfileManagementViewModel> logger)
}
From SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs:
public class GraphClientFactory
{
public async Task<GraphServiceClient> CreateClientAsync(string clientId, CancellationToken ct);
}
Graph API for auto-pull (from RESEARCH):
// Endpoint: GET /organization/{orgId}/branding/localizations/default/squareLogo
var orgs = await graphClient.Organization.GetAsync();
var orgId = orgs?.Value?.FirstOrDefault()?.Id;
var stream = await graphClient.Organization[orgId]
.Branding.Localizations["default"].SquareLogo.GetAsync();
// Returns: Stream (image bytes), 404 if no branding, empty body if logo not set
2. Add `ImportLogoFromBytesAsync` to `IBrandingService.cs`:
```csharp
Task<LogoData> ImportLogoFromBytesAsync(byte[] bytes);
```
3. Implement in `BrandingService.cs`:
```csharp
public Task<LogoData> ImportLogoFromBytesAsync(byte[] bytes)
{
var mimeType = DetectMimeType(bytes);
if (bytes.Length > MaxSizeBytes)
{
bytes = CompressToLimit(bytes, mimeType, MaxSizeBytes);
}
return Task.FromResult(new LogoData
{
Base64 = Convert.ToBase64String(bytes),
MimeType = mimeType
});
}
```
This extracts the validation/compression logic that `ImportLogoAsync` also uses. Refactor `ImportLogoAsync` to delegate to `ImportLogoFromBytesAsync` after reading the file:
```csharp
public async Task<LogoData> ImportLogoAsync(string filePath)
{
var bytes = await File.ReadAllBytesAsync(filePath);
return await ImportLogoFromBytesAsync(bytes);
}
```
4. Extend `ProfileServiceTests.cs` (the file should already exist) with tests for `UpdateProfileAsync`. If it does not exist, create it following the same pattern as `BrandingRepositoryTests.cs` (IDisposable, temp file, real repository).
5. Add `ImportLogoFromBytesAsync` tests to existing `BrandingServiceTests.cs`. Create a valid PNG byte array (same technique as existing tests — 8-byte PNG signature + minimal IHDR/IEND) and verify the returned LogoData. Test invalid bytes throw `InvalidDataException`.
dotnet build --no-restore -warnaserror && dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~ProfileService|FullyQualifiedName~BrandingService" --no-build -q
ProfileService has UpdateProfileAsync that persists profile changes. BrandingService has ImportLogoFromBytesAsync for raw byte validation. ImportLogoAsync delegates to ImportLogoFromBytesAsync. All tests pass.
Task 2: Add MSP logo commands to SettingsViewModel and client logo commands to ProfileManagementViewModel
SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs,
SharepointToolbox/ViewModels/ProfileManagementViewModel.cs,
SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs,
SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs
**SettingsViewModel modifications:**
1. Add `using SharepointToolbox.Services;` if not already present. Add `using Microsoft.Win32;` (already present).
2. Add field: `private readonly IBrandingService _brandingService;`
3. Add properties:
```csharp
private string? _mspLogoPreview;
public string? MspLogoPreview
{
get => _mspLogoPreview;
private set { _mspLogoPreview = value; OnPropertyChanged(); }
}
```
4. Add commands:
```csharp
public IAsyncRelayCommand BrowseMspLogoCommand { get; }
public IAsyncRelayCommand ClearMspLogoCommand { get; }
```
5. Modify constructor to accept `IBrandingService brandingService` and initialize:
```csharp
public SettingsViewModel(SettingsService settingsService, IBrandingService brandingService, ILogger<FeatureViewModelBase> logger)
: base(logger)
{
_settingsService = settingsService;
_brandingService = brandingService;
BrowseFolderCommand = new RelayCommand(BrowseFolder);
BrowseMspLogoCommand = new AsyncRelayCommand(BrowseMspLogoAsync);
ClearMspLogoCommand = new AsyncRelayCommand(ClearMspLogoAsync);
}
```
6. Add `LoadAsync` extension — after loading settings, also load current MSP logo preview:
```csharp
// At end of existing LoadAsync:
var mspLogo = await _brandingService.GetMspLogoAsync();
MspLogoPreview = mspLogo is not null ? $"data:{mspLogo.MimeType};base64,{mspLogo.Base64}" : null;
```
7. Implement commands:
```csharp
private async Task BrowseMspLogoAsync()
{
var dialog = new OpenFileDialog
{
Title = "Select MSP logo",
Filter = "Image files (*.png;*.jpg;*.jpeg)|*.png;*.jpg;*.jpeg",
};
if (dialog.ShowDialog() != true) return;
try
{
var logo = await _brandingService.ImportLogoAsync(dialog.FileName);
await _brandingService.SaveMspLogoAsync(logo);
MspLogoPreview = $"data:{logo.MimeType};base64,{logo.Base64}";
}
catch (Exception ex)
{
StatusMessage = ex.Message;
}
}
private async Task ClearMspLogoAsync()
{
await _brandingService.ClearMspLogoAsync();
MspLogoPreview = null;
}
```
**ProfileManagementViewModel modifications:**
1. Add fields:
```csharp
private readonly IBrandingService _brandingService;
private readonly Infrastructure.Auth.GraphClientFactory _graphClientFactory;
```
Add the type alias at the top of the file to avoid conflict with Microsoft.Graph.GraphClientFactory:
```csharp
using AppGraphClientFactory = SharepointToolbox.Infrastructure.Auth.GraphClientFactory;
```
2. Add commands:
```csharp
public IAsyncRelayCommand BrowseClientLogoCommand { get; }
public IAsyncRelayCommand ClearClientLogoCommand { get; }
public IAsyncRelayCommand AutoPullClientLogoCommand { get; }
```
3. Modify constructor:
```csharp
public ProfileManagementViewModel(
ProfileService profileService,
IBrandingService brandingService,
AppGraphClientFactory graphClientFactory,
ILogger<ProfileManagementViewModel> logger)
{
_profileService = profileService;
_brandingService = brandingService;
_graphClientFactory = graphClientFactory;
_logger = logger;
AddCommand = new AsyncRelayCommand(AddAsync, CanAdd);
RenameCommand = new AsyncRelayCommand(RenameAsync, () => SelectedProfile != null && !string.IsNullOrWhiteSpace(NewName));
DeleteCommand = new AsyncRelayCommand(DeleteAsync, () => SelectedProfile != null);
BrowseClientLogoCommand = new AsyncRelayCommand(BrowseClientLogoAsync, () => SelectedProfile != null);
ClearClientLogoCommand = new AsyncRelayCommand(ClearClientLogoAsync, () => SelectedProfile != null);
AutoPullClientLogoCommand = new AsyncRelayCommand(AutoPullClientLogoAsync, () => SelectedProfile != null);
}
```
4. Update `NotifyCommandsCanExecuteChanged` and add `OnSelectedProfileChanged`:
```csharp
partial void OnSelectedProfileChanged(TenantProfile? value)
{
BrowseClientLogoCommand.NotifyCanExecuteChanged();
ClearClientLogoCommand.NotifyCanExecuteChanged();
AutoPullClientLogoCommand.NotifyCanExecuteChanged();
RenameCommand.NotifyCanExecuteChanged();
DeleteCommand.NotifyCanExecuteChanged();
}
```
5. Implement commands:
```csharp
private async Task BrowseClientLogoAsync()
{
if (SelectedProfile == null) return;
var dialog = new OpenFileDialog
{
Title = "Select client logo",
Filter = "Image files (*.png;*.jpg;*.jpeg)|*.png;*.jpg;*.jpeg",
};
if (dialog.ShowDialog() != true) return;
try
{
var logo = await _brandingService.ImportLogoAsync(dialog.FileName);
SelectedProfile.ClientLogo = logo;
await _profileService.UpdateProfileAsync(SelectedProfile);
ValidationMessage = string.Empty;
}
catch (Exception ex)
{
ValidationMessage = ex.Message;
_logger.LogError(ex, "Failed to import client logo.");
}
}
private async Task ClearClientLogoAsync()
{
if (SelectedProfile == null) return;
try
{
SelectedProfile.ClientLogo = null;
await _profileService.UpdateProfileAsync(SelectedProfile);
ValidationMessage = string.Empty;
}
catch (Exception ex)
{
ValidationMessage = ex.Message;
_logger.LogError(ex, "Failed to clear client logo.");
}
}
private async Task AutoPullClientLogoAsync()
{
if (SelectedProfile == null) return;
try
{
var graphClient = await _graphClientFactory.CreateClientAsync(
SelectedProfile.ClientId, CancellationToken.None);
var orgs = await graphClient.Organization.GetAsync();
var orgId = orgs?.Value?.FirstOrDefault()?.Id;
if (orgId is null)
{
ValidationMessage = "Could not determine organization ID.";
return;
}
var stream = await graphClient.Organization[orgId]
.Branding.Localizations["default"].SquareLogo.GetAsync();
if (stream is null || stream.Length == 0)
{
ValidationMessage = "No branding logo found for this tenant.";
return;
}
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
var bytes = ms.ToArray();
var logo = await _brandingService.ImportLogoFromBytesAsync(bytes);
SelectedProfile.ClientLogo = logo;
await _profileService.UpdateProfileAsync(SelectedProfile);
ValidationMessage = "Client logo pulled from Entra branding.";
}
catch (Microsoft.Graph.Models.ODataErrors.ODataError ex) when (ex.ResponseStatusCode == 404)
{
ValidationMessage = "No Entra branding configured for this tenant.";
}
catch (Exception ex)
{
ValidationMessage = $"Failed to pull logo: {ex.Message}";
_logger.LogWarning(ex, "Auto-pull client logo failed.");
}
}
```
Add required usings: `using System.IO;`, `using Microsoft.Win32;`, `using Microsoft.Graph.Models.ODataErrors;`
**Tests:**
6. Create `SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs`:
- Test that `BrowseMspLogoCommand` is not null after construction
- Test that `ClearMspLogoCommand` is not null after construction
- Test that `ClearMspLogoAsync` calls `IBrandingService.ClearMspLogoAsync` and sets `MspLogoPreview = null`
- Use Moq to mock `IBrandingService` and `ILogger<FeatureViewModelBase>`
- Cannot test `BrowseMspLogoAsync` fully (OpenFileDialog requires UI thread), but can test the command exists and ClearMspLogo path works
7. Create `SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs`:
- Test that all 3 commands are not null after construction
- Test `ClearClientLogoAsync`: mock ProfileService, set SelectedProfile, call command, verify ClientLogo is null and UpdateProfileAsync was called
- Test `AutoPullClientLogoCommand` can execute check: false when SelectedProfile is null, true when set
- Mock GraphClientFactory, IBrandingService, ProfileService, ILogger
- Test auto-pull 404 handling: mock GraphServiceClient to throw ODataError with 404 status code, verify ValidationMessage is set and no exception propagates
dotnet build --no-restore -warnaserror && dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~SettingsViewModel|FullyQualifiedName~ProfileManagementViewModel" --no-build -q
SettingsViewModel has BrowseMspLogoCommand and ClearMspLogoCommand. ProfileManagementViewModel has BrowseClientLogoCommand, ClearClientLogoCommand, and AutoPullClientLogoCommand. ProfileService.UpdateProfileAsync persists profile changes. All commands are exercisable without View. Auto-pull handles 404 gracefully. All tests pass.
```bash
dotnet build --no-restore -warnaserror
dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~SettingsViewModel|FullyQualifiedName~ProfileManagementViewModel|FullyQualifiedName~ProfileService|FullyQualifiedName~BrandingService" --no-build -q
dotnet test SharepointToolbox.Tests --no-build -q
```
All three commands must pass with zero failures.
<success_criteria>
- SettingsViewModel exposes BrowseMspLogoCommand and ClearMspLogoCommand (IAsyncRelayCommand)
- ProfileManagementViewModel exposes BrowseClientLogoCommand, ClearClientLogoCommand, AutoPullClientLogoCommand
- ProfileService.UpdateProfileAsync updates and persists existing profiles
- BrandingService.ImportLogoFromBytesAsync validates raw bytes and returns LogoData
- ImportLogoAsync delegates to ImportLogoFromBytesAsync (no code duplication)
- Auto-pull uses squareLogo endpoint, handles 404 gracefully with user message
- All commands exercisable without View (ViewModel-testable)
- Full test suite passes with no regressions </success_criteria>