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>(),
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);
// Set a default profile so RunOperationAsync doesn't early-return
vm._currentProfile = new TenantProfile
{
Name = "Test",
TenantUrl = "https://contoso.sharepoint.com",
ClientId = "test-client-id"
};
return (vm, mockAudit, mockGraph);
}
// ── Test 1: RunOperation calls AuditUsersAsync ────────────────────────────
[Fact]
public async Task RunOperation_calls_AuditUsersAsync()
{
var (vm, auditMock, _) = CreateViewModel();
vm.SelectedUsers.Add(MakeUser());
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
new List { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
await vm.TestRunOperationAsync(CancellationToken.None, new Progress());
auditMock.Verify(
s => s.AuditUsersAsync(
It.IsAny(),
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());
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
new List { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
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());
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
new List { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
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());
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
new List { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
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.FilterText);
}
// ── Test 5: RunOperation uses GlobalSites directly ─────────────────────
[Fact]
public async Task RunOperation_fails_gracefully_without_global_sites()
{
var (vm, auditMock, _) = CreateViewModel();
vm.SelectedUsers.Add(MakeUser());
// Do NOT send GlobalSitesChangedMessage — no sites selected
await vm.TestRunOperationAsync(CancellationToken.None, new Progress());
// Should not call audit service — early return with status message
auditMock.Verify(
s => s.AuditUsersAsync(
It.IsAny(),
It.IsAny(),
It.IsAny>(),
It.IsAny>(),
It.IsAny(),
It.IsAny>(),
It.IsAny()),
Times.Never);
}
// ── 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());
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
new List { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
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);
}
}