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()); 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>(), 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); } }