docs(11): create phase plan for HTML export branding and ViewModel integration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
506
.planning/phases/11-html-export-branding/11-04-PLAN.md
Normal file
506
.planning/phases/11-html-export-branding/11-04-PLAN.md
Normal file
@@ -0,0 +1,506 @@
|
||||
---
|
||||
phase: 11-html-export-branding
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- SharepointToolbox/Services/ProfileService.cs
|
||||
- SharepointToolbox/Services/IBrandingService.cs
|
||||
- SharepointToolbox/Services/BrandingService.cs
|
||||
- SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs
|
||||
- SharepointToolbox/ViewModels/ProfileManagementViewModel.cs
|
||||
- SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs
|
||||
- SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs
|
||||
- SharepointToolbox.Tests/Services/ProfileServiceTests.cs
|
||||
autonomous: true
|
||||
requirements:
|
||||
- BRAND-04
|
||||
- BRAND-05
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "SettingsViewModel exposes BrowseMspLogoCommand and ClearMspLogoCommand that are exercisable without a View"
|
||||
- "ProfileManagementViewModel exposes BrowseClientLogoCommand, ClearClientLogoCommand, and AutoPullClientLogoCommand"
|
||||
- "ProfileService.UpdateProfileAsync persists changes to an existing profile (including ClientLogo)"
|
||||
- "AutoPullClientLogoCommand fetches squareLogo from Entra branding API and stores it as client logo"
|
||||
- "Auto-pull handles 404 (no Entra branding) gracefully with an informational message, no exception"
|
||||
- "BrandingService.ImportLogoFromBytesAsync validates and converts raw bytes to LogoData"
|
||||
artifacts:
|
||||
- path: "SharepointToolbox/Services/ProfileService.cs"
|
||||
provides: "UpdateProfileAsync method for persisting profile changes"
|
||||
contains: "UpdateProfileAsync"
|
||||
- path: "SharepointToolbox/Services/IBrandingService.cs"
|
||||
provides: "ImportLogoFromBytesAsync method declaration"
|
||||
contains: "ImportLogoFromBytesAsync"
|
||||
- path: "SharepointToolbox/Services/BrandingService.cs"
|
||||
provides: "ImportLogoFromBytesAsync implementation with magic byte validation"
|
||||
contains: "ImportLogoFromBytesAsync"
|
||||
- path: "SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs"
|
||||
provides: "MSP logo browse/clear commands"
|
||||
contains: "BrowseMspLogoCommand"
|
||||
- path: "SharepointToolbox/ViewModels/ProfileManagementViewModel.cs"
|
||||
provides: "Client logo browse/clear/auto-pull commands"
|
||||
contains: "AutoPullClientLogoCommand"
|
||||
- path: "SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs"
|
||||
provides: "Tests for MSP logo commands"
|
||||
min_lines: 40
|
||||
- path: "SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs"
|
||||
provides: "Tests for client logo commands and auto-pull"
|
||||
min_lines: 60
|
||||
key_links:
|
||||
- from: "SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs"
|
||||
to: "SharepointToolbox/Services/IBrandingService.cs"
|
||||
via: "constructor injection"
|
||||
pattern: "IBrandingService _brandingService"
|
||||
- from: "SharepointToolbox/ViewModels/ProfileManagementViewModel.cs"
|
||||
to: "SharepointToolbox/Services/ProfileService.cs"
|
||||
via: "UpdateProfileAsync call"
|
||||
pattern: "_profileService\\.UpdateProfileAsync"
|
||||
- from: "SharepointToolbox/ViewModels/ProfileManagementViewModel.cs"
|
||||
to: "Microsoft.Graph"
|
||||
via: "GraphClientFactory.CreateClientAsync"
|
||||
pattern: "Organization.*Branding.*SquareLogo"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add logo management commands to SettingsViewModel and ProfileManagementViewModel, add UpdateProfileAsync to ProfileService, add ImportLogoFromBytesAsync to BrandingService, and implement Entra branding auto-pull.
|
||||
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<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>
|
||||
|
||||
<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
|
||||
|
||||
<interfaces>
|
||||
<!-- From Phase 10 -->
|
||||
From SharepointToolbox/Services/IBrandingService.cs:
|
||||
```csharp
|
||||
public interface IBrandingService
|
||||
{
|
||||
Task<LogoData> ImportLogoAsync(string filePath);
|
||||
Task SaveMspLogoAsync(LogoData logo);
|
||||
Task ClearMspLogoAsync();
|
||||
Task<LogoData?> GetMspLogoAsync();
|
||||
}
|
||||
```
|
||||
|
||||
From SharepointToolbox/Services/BrandingService.cs:
|
||||
```csharp
|
||||
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:
|
||||
```csharp
|
||||
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:
|
||||
```csharp
|
||||
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:
|
||||
```csharp
|
||||
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:
|
||||
```csharp
|
||||
public class GraphClientFactory
|
||||
{
|
||||
public async Task<GraphServiceClient> CreateClientAsync(string clientId, CancellationToken ct);
|
||||
}
|
||||
```
|
||||
|
||||
Graph API for auto-pull (from RESEARCH):
|
||||
```csharp
|
||||
// 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
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Add UpdateProfileAsync to ProfileService and ImportLogoFromBytesAsync to BrandingService</name>
|
||||
<files>
|
||||
SharepointToolbox/Services/ProfileService.cs,
|
||||
SharepointToolbox/Services/IBrandingService.cs,
|
||||
SharepointToolbox/Services/BrandingService.cs,
|
||||
SharepointToolbox.Tests/Services/ProfileServiceTests.cs
|
||||
</files>
|
||||
<behavior>
|
||||
- Test 1: ProfileService.UpdateProfileAsync updates an existing profile and persists the change (round-trip through repository)
|
||||
- Test 2: ProfileService.UpdateProfileAsync throws KeyNotFoundException when profile name not found
|
||||
- Test 3: BrandingService.ImportLogoFromBytesAsync with valid PNG bytes returns LogoData with correct MimeType and Base64
|
||||
- Test 4: BrandingService.ImportLogoFromBytesAsync with invalid bytes throws InvalidDataException
|
||||
</behavior>
|
||||
<action>
|
||||
1. Add `UpdateProfileAsync` to `ProfileService.cs`:
|
||||
```csharp
|
||||
public async Task UpdateProfileAsync(TenantProfile profile)
|
||||
{
|
||||
var profiles = (await _repository.LoadAsync()).ToList();
|
||||
var idx = profiles.FindIndex(p => p.Name == profile.Name);
|
||||
if (idx < 0) throw new KeyNotFoundException($"Profile '{profile.Name}' not found.");
|
||||
profiles[idx] = profile;
|
||||
await _repository.SaveAsync(profiles);
|
||||
}
|
||||
```
|
||||
|
||||
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`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dotnet build --no-restore -warnaserror && dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~ProfileService|FullyQualifiedName~BrandingService" --no-build -q</automated>
|
||||
</verify>
|
||||
<done>ProfileService has UpdateProfileAsync that persists profile changes. BrandingService has ImportLogoFromBytesAsync for raw byte validation. ImportLogoAsync delegates to ImportLogoFromBytesAsync. All tests pass.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add MSP logo commands to SettingsViewModel and client logo commands to ProfileManagementViewModel</name>
|
||||
<files>
|
||||
SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs,
|
||||
SharepointToolbox/ViewModels/ProfileManagementViewModel.cs,
|
||||
SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs,
|
||||
SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs
|
||||
</files>
|
||||
<action>
|
||||
**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
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dotnet build --no-restore -warnaserror && dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~SettingsViewModel|FullyQualifiedName~ProfileManagementViewModel" --no-build -q</automated>
|
||||
</verify>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```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.
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/11-html-export-branding/11-04-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user