test(07-08): add export and ViewModel unit tests
- UserAccessCsvExportServiceTests (5): summary section, data header, RFC 4180 quote escaping, 7-column count, WriteSingleFileAsync multi-user output - UserAccessHtmlExportServiceTests (7): DOCTYPE, stats cards, dual-view sections, access type badges, filterTable JS, toggleView JS, HTML entity encoding - UserAccessAuditViewModelTests (8): AuditUsersAsync invocation, results population, summary properties computation, tenant switch reset, GlobalSitesChanged update, override guard, CanExport false/true states
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using SharepointToolbox.Core.Messages;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Services;
|
||||
using SharepointToolbox.ViewModels;
|
||||
using SharepointToolbox.ViewModels.Tabs;
|
||||
|
||||
namespace SharepointToolbox.Tests.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for UserAccessAuditViewModel (Phase 7 Plan 08).
|
||||
/// Verifies: AuditUsersAsync invocation, results population, summary properties,
|
||||
/// tenant switch reset, global sites message, override guard, CanExport state.
|
||||
/// </summary>
|
||||
public class UserAccessAuditViewModelTests
|
||||
{
|
||||
// ── Reset messenger between tests ─────────────────────────────────────────
|
||||
|
||||
public UserAccessAuditViewModelTests()
|
||||
{
|
||||
WeakReferenceMessenger.Default.Reset();
|
||||
}
|
||||
|
||||
// ── Helper factories ──────────────────────────────────────────────────────
|
||||
|
||||
private static UserAccessEntry MakeEntry(
|
||||
string userLogin = "alice@contoso.com",
|
||||
string siteUrl = "https://contoso.sharepoint.com",
|
||||
bool isHighPrivilege = false) =>
|
||||
new("Alice", userLogin, siteUrl, "Contoso", "List", "Docs",
|
||||
siteUrl + "/Docs", "Read", AccessType.Direct, "Direct Permissions",
|
||||
isHighPrivilege, false);
|
||||
|
||||
private static GraphUserResult MakeUser(
|
||||
string display = "Alice Smith",
|
||||
string upn = "alice@contoso.com") =>
|
||||
new(display, upn, upn);
|
||||
|
||||
/// <summary>Creates a ViewModel wired with a mock IUserAccessAuditService.</summary>
|
||||
private static (UserAccessAuditViewModel vm, Mock<IUserAccessAuditService> auditMock)
|
||||
CreateViewModel(IReadOnlyList<UserAccessEntry>? auditResult = null)
|
||||
{
|
||||
var mockAudit = new Mock<IUserAccessAuditService>();
|
||||
mockAudit
|
||||
.Setup(s => s.AuditUsersAsync(
|
||||
It.IsAny<ISessionManager>(),
|
||||
It.IsAny<IReadOnlyList<string>>(),
|
||||
It.IsAny<IReadOnlyList<SiteInfo>>(),
|
||||
It.IsAny<ScanOptions>(),
|
||||
It.IsAny<IProgress<OperationProgress>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(auditResult ?? Array.Empty<UserAccessEntry>());
|
||||
|
||||
var mockGraph = new Mock<IGraphUserSearchService>();
|
||||
var mockSession = new Mock<ISessionManager>();
|
||||
|
||||
var vm = new UserAccessAuditViewModel(
|
||||
mockAudit.Object,
|
||||
mockGraph.Object,
|
||||
mockSession.Object,
|
||||
NullLogger<FeatureViewModelBase>.Instance);
|
||||
|
||||
return (vm, mockAudit);
|
||||
}
|
||||
|
||||
// ── Test 1: RunOperation calls AuditUsersAsync ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RunOperation_calls_AuditUsersAsync()
|
||||
{
|
||||
var (vm, auditMock) = CreateViewModel();
|
||||
|
||||
vm.SelectedUsers.Add(MakeUser());
|
||||
vm.SelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com", "Contoso"));
|
||||
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
|
||||
auditMock.Verify(
|
||||
s => s.AuditUsersAsync(
|
||||
It.IsAny<ISessionManager>(),
|
||||
It.IsAny<IReadOnlyList<string>>(),
|
||||
It.IsAny<IReadOnlyList<SiteInfo>>(),
|
||||
It.IsAny<ScanOptions>(),
|
||||
It.IsAny<IProgress<OperationProgress>>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
// ── Test 2: RunOperation populates Results ────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RunOperation_populates_results()
|
||||
{
|
||||
var entries = new List<UserAccessEntry>
|
||||
{
|
||||
MakeEntry(),
|
||||
MakeEntry(userLogin: "bob@contoso.com")
|
||||
};
|
||||
|
||||
var (vm, _) = CreateViewModel(entries);
|
||||
|
||||
vm.SelectedUsers.Add(MakeUser());
|
||||
vm.SelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com", "Contoso"));
|
||||
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
|
||||
Assert.Equal(2, vm.Results.Count);
|
||||
}
|
||||
|
||||
// ── Test 3: RunOperation updates summary properties ───────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RunOperation_updates_summary_properties()
|
||||
{
|
||||
var entries = new List<UserAccessEntry>
|
||||
{
|
||||
MakeEntry(userLogin: "alice@contoso.com", siteUrl: "https://contoso.sharepoint.com/sites/s1", isHighPrivilege: true),
|
||||
MakeEntry(userLogin: "alice@contoso.com", siteUrl: "https://contoso.sharepoint.com/sites/s2", isHighPrivilege: false),
|
||||
MakeEntry(userLogin: "bob@contoso.com", siteUrl: "https://contoso.sharepoint.com/sites/s1", isHighPrivilege: true)
|
||||
};
|
||||
|
||||
var (vm, _) = CreateViewModel(entries);
|
||||
|
||||
vm.SelectedUsers.Add(MakeUser());
|
||||
vm.SelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com", "Contoso"));
|
||||
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
|
||||
Assert.Equal(3, vm.TotalAccessCount);
|
||||
Assert.Equal(2, vm.SitesCount);
|
||||
Assert.Equal(2, vm.HighPrivilegeCount);
|
||||
}
|
||||
|
||||
// ── Test 4: OnTenantSwitched resets state ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task OnTenantSwitched_resets_state()
|
||||
{
|
||||
var entries = new List<UserAccessEntry> { MakeEntry() };
|
||||
var (vm, _) = CreateViewModel(entries);
|
||||
|
||||
// Populate state
|
||||
vm.SelectedUsers.Add(MakeUser());
|
||||
vm.SelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com", "Contoso"));
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
Assert.NotEmpty(vm.Results);
|
||||
Assert.NotEmpty(vm.SelectedUsers);
|
||||
|
||||
// Act: send TenantSwitchedMessage
|
||||
var newProfile = new TenantProfile
|
||||
{
|
||||
Name = "NewTenant",
|
||||
TenantUrl = "https://newtenant.sharepoint.com",
|
||||
ClientId = "new-client-id"
|
||||
};
|
||||
WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(newProfile));
|
||||
|
||||
// Assert: state cleared
|
||||
Assert.Empty(vm.Results);
|
||||
Assert.Empty(vm.SelectedUsers);
|
||||
Assert.Empty(vm.SelectedSites);
|
||||
Assert.Empty(vm.FilterText);
|
||||
}
|
||||
|
||||
// ── Test 5: GlobalSitesChanged updates SelectedSites ─────────────────────
|
||||
|
||||
[Fact]
|
||||
public void OnGlobalSitesChanged_updates_selected_sites()
|
||||
{
|
||||
var (vm, _) = CreateViewModel();
|
||||
var sites = new List<SiteInfo>
|
||||
{
|
||||
new("https://contoso.sharepoint.com/sites/hr", "HR"),
|
||||
new("https://contoso.sharepoint.com/sites/finance", "Finance")
|
||||
}.AsReadOnly();
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
|
||||
|
||||
Assert.Equal(2, vm.SelectedSites.Count);
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/hr", vm.SelectedSites[0].Url);
|
||||
}
|
||||
|
||||
// ── Test 6: GlobalSitesChanged skipped when override active ──────────────
|
||||
|
||||
[Fact]
|
||||
public void OnGlobalSitesChanged_skipped_when_override()
|
||||
{
|
||||
var (vm, _) = CreateViewModel();
|
||||
|
||||
// Add a local site and set the override flag via reflection
|
||||
var localSite = new SiteInfo("https://contoso.sharepoint.com/sites/local", "Local");
|
||||
vm.SelectedSites.Add(localSite);
|
||||
|
||||
var field = typeof(UserAccessAuditViewModel)
|
||||
.GetField("_hasLocalSiteOverride",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
field!.SetValue(vm, true);
|
||||
|
||||
// Act: send global sites message
|
||||
var sites = new List<SiteInfo>
|
||||
{
|
||||
new("https://contoso.sharepoint.com/sites/global1", "Global1")
|
||||
}.AsReadOnly();
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
|
||||
|
||||
// Assert: SelectedSites unchanged (override prevented update)
|
||||
Assert.Single(vm.SelectedSites);
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/local", vm.SelectedSites[0].Url);
|
||||
}
|
||||
|
||||
// ── Test 7: CanExport false when no results ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void CanExport_false_when_no_results()
|
||||
{
|
||||
var (vm, _) = CreateViewModel();
|
||||
|
||||
// Results is empty by default
|
||||
Assert.Empty(vm.Results);
|
||||
Assert.False(vm.ExportCsvCommand.CanExecute(null));
|
||||
Assert.False(vm.ExportHtmlCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
// ── Test 8: CanExport true when has results ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CanExport_true_when_has_results()
|
||||
{
|
||||
var entries = new List<UserAccessEntry> { MakeEntry() };
|
||||
var (vm, _) = CreateViewModel(entries);
|
||||
|
||||
vm.SelectedUsers.Add(MakeUser());
|
||||
vm.SelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com", "Contoso"));
|
||||
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
|
||||
Assert.NotEmpty(vm.Results);
|
||||
Assert.True(vm.ExportCsvCommand.CanExecute(null));
|
||||
Assert.True(vm.ExportHtmlCommand.CanExecute(null));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user