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;
///
/// 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.
///
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);
/// Creates a ViewModel wired with mock services.
private static (UserAccessAuditViewModel vm, Mock auditMock, Mock graphMock)
CreateViewModel(IReadOnlyList? auditResult = null)
{
var mockAudit = new Mock();
mockAudit
.Setup(s => s.AuditUsersAsync(
It.IsAny(),
It.IsAny>(),
It.IsAny>(),
It.IsAny(),
It.IsAny>(),
It.IsAny()))
.ReturnsAsync(auditResult ?? Array.Empty());
var mockGraph = new Mock();
var mockSession = new Mock();
var vm = new UserAccessAuditViewModel(
mockAudit.Object,
mockGraph.Object,
mockSession.Object,
NullLogger.Instance);
return (vm, mockAudit, mockGraph);
}
// ── 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());
auditMock.Verify(
s => s.AuditUsersAsync(
It.IsAny(),
It.IsAny>(),
It.IsAny>(),
It.IsAny(),
It.IsAny>(),
It.IsAny()),
Times.Once);
}
// ── Test 2: RunOperation populates Results ────────────────────────────────
[Fact]
public async Task RunOperation_populates_results()
{
var entries = new List
{
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());
Assert.Equal(2, vm.Results.Count);
}
// ── Test 3: RunOperation updates summary properties ───────────────────────
[Fact]
public async Task RunOperation_updates_summary_properties()
{
var entries = new List
{
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());
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 { 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());
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
{
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
{
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 { 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());
Assert.NotEmpty(vm.Results);
Assert.True(vm.ExportCsvCommand.CanExecute(null));
Assert.True(vm.ExportHtmlCommand.CanExecute(null));
}
// ── Test 9: Debounced search triggers SearchUsersAsync ───────────────────
[Fact]
public async Task SearchQuery_debounced_calls_SearchUsersAsync()
{
var graphResults = new List
{
new("Alice Smith", "alice@contoso.com", "alice@contoso.com")
};
var (vm, _, graphMock) = CreateViewModel();
graphMock
.Setup(s => s.SearchUsersAsync(
It.IsAny(),
It.Is(q => q == "Ali"),
It.IsAny(),
It.IsAny()))
.ReturnsAsync(graphResults);
// Set a TenantProfile so _currentProfile is non-null
var profile = new TenantProfile
{
Name = "Test",
TenantUrl = "https://contoso.sharepoint.com",
ClientId = "test-client-id"
};
WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(profile));
// Act: set SearchQuery which triggers OnSearchQueryChanged → DebounceSearchAsync
vm.SearchQuery = "Ali";
// Wait longer than 300ms debounce to allow async fire-and-forget to complete
await Task.Delay(600);
// Assert: SearchUsersAsync was called with the query
graphMock.Verify(
s => s.SearchUsersAsync(
It.IsAny(),
"Ali",
It.IsAny(),
It.IsAny()),
Times.Once);
}
}