From 4ba4de6106bc2d2d5c9a8eabaf599ec2c47a667f Mon Sep 17 00:00:00 2001 From: Dev Date: Wed, 8 Apr 2026 16:07:53 +0200 Subject: [PATCH] feat(13-02): add directory browse mode with paginated load, member/guest filter, and sortable ICollectionView - Inject IGraphUserDirectoryService into UserAccessAuditViewModel (both constructors) - Add IsBrowseMode toggle, DirectoryUsers collection, DirectoryUsersView with sort/filter - Add LoadDirectoryCommand with progress reporting, cancellation, and error handling - Add IncludeGuests toggle for in-memory member/guest filtering (no new Graph request) - Add DirectoryFilterText for DisplayName/UPN/Department/JobTitle text search - Add DirectoryUserCount computed property reflecting filtered view count - Update OnTenantSwitched to clear all directory state - Add 16 comprehensive unit tests covering all directory browse behaviors Co-Authored-By: Claude Opus 4.6 (1M context) --- .../UserAccessAuditViewModelDirectoryTests.cs | 351 ++++++++++++++++++ .../Tabs/UserAccessAuditViewModel.cs | 180 ++++++++- 2 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs diff --git a/SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs b/SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs new file mode 100644 index 0000000..1bcb1cc --- /dev/null +++ b/SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs @@ -0,0 +1,351 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using SharepointToolbox.Core.Models; +using SharepointToolbox.Services; +using SharepointToolbox.ViewModels; +using SharepointToolbox.ViewModels.Tabs; + +namespace SharepointToolbox.Tests.ViewModels; + +/// +/// Unit tests for directory browse mode in UserAccessAuditViewModel (Phase 13 Plan 02). +/// Verifies: directory load, progress, cancellation, member/guest filter, text filter, +/// sorting, tenant switch reset, and no regression on search mode. +/// +[Trait("Category", "Unit")] +public class UserAccessAuditViewModelDirectoryTests +{ + public UserAccessAuditViewModelDirectoryTests() + { + WeakReferenceMessenger.Default.Reset(); + } + + // ── Helper factories ────────────────────────────────────────────────────── + + private static GraphDirectoryUser MakeMember(string name = "Alice", string dept = "IT", string jobTitle = "Engineer") => + new(name, $"{name.ToLower().Replace(" ", "")}@contoso.com", null, dept, jobTitle, "Member"); + + private static GraphDirectoryUser MakeGuest(string name = "Bob External") => + new(name, $"{name.ToLower().Replace(" ", "")}@external.com", null, null, null, "Guest"); + + private static (UserAccessAuditViewModel vm, Mock dirMock, Mock auditMock) + CreateViewModel(IReadOnlyList? directoryResult = null) + { + var mockAudit = new Mock(); + var mockGraph = new Mock(); + var mockSession = new Mock(); + var mockDir = new Mock(); + mockDir.Setup(s => s.GetUsersAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(directoryResult ?? Array.Empty()); + + var vm = new UserAccessAuditViewModel( + mockAudit.Object, + mockGraph.Object, + mockSession.Object, + NullLogger.Instance, + graphUserDirectoryService: mockDir.Object); + + vm._currentProfile = new TenantProfile + { + Name = "Test", + TenantUrl = "https://contoso.sharepoint.com", + ClientId = "test-client-id" + }; + + return (vm, mockDir, mockAudit); + } + + // ── Test 1: IsBrowseMode defaults to false ─────────────────────────────── + + [Fact] + public void IsBrowseMode_defaults_to_false() + { + var (vm, _, _) = CreateViewModel(); + Assert.False(vm.IsBrowseMode); + } + + // ── Test 2: DirectoryUsers is empty by default ─────────────────────────── + + [Fact] + public void DirectoryUsers_empty_by_default() + { + var (vm, _, _) = CreateViewModel(); + Assert.Empty(vm.DirectoryUsers); + } + + // ── Test 3: Commands are not null ───────────────────────────────────────── + + [Fact] + public void LoadDirectoryCommand_and_CancelDirectoryLoadCommand_not_null() + { + var (vm, _, _) = CreateViewModel(); + Assert.NotNull(vm.LoadDirectoryCommand); + Assert.NotNull(vm.CancelDirectoryLoadCommand); + } + + // ── Test 4: LoadDirectoryAsync populates DirectoryUsers ────────────────── + + [Fact] + public async Task LoadDirectoryAsync_populates_DirectoryUsers() + { + var users = new List { MakeMember("Alice"), MakeMember("Charlie") }; + var (vm, _, _) = CreateViewModel(users); + + await vm.TestLoadDirectoryAsync(); + + Assert.Equal(2, vm.DirectoryUsers.Count); + Assert.Contains(vm.DirectoryUsers, u => u.DisplayName == "Alice"); + Assert.Contains(vm.DirectoryUsers, u => u.DisplayName == "Charlie"); + } + + // ── Test 5: LoadDirectoryAsync reports progress via DirectoryLoadStatus ── + + [Fact] + public async Task LoadDirectoryAsync_sets_DirectoryLoadStatus_on_completion() + { + var users = new List { MakeMember("Alice") }; + var (vm, _, _) = CreateViewModel(users); + + await vm.TestLoadDirectoryAsync(); + + Assert.Equal("1 users loaded", vm.DirectoryLoadStatus); + } + + // ── Test 6: LoadDirectoryAsync with no profile sets StatusMessage ───────── + + [Fact] + public async Task LoadDirectoryAsync_with_no_profile_sets_StatusMessage() + { + var (vm, _, _) = CreateViewModel(); + vm._currentProfile = null; + + await vm.TestLoadDirectoryAsync(); + + Assert.Equal("No tenant profile selected. Please connect first.", vm.StatusMessage); + Assert.Empty(vm.DirectoryUsers); + } + + // ── Test 7: CancelDirectoryLoadCommand cancels in-flight load ──────────── + + [Fact] + public async Task CancelDirectoryLoad_cancels_inflight_load() + { + var tcs = new TaskCompletionSource>(); + var mockDir = new Mock(); + mockDir.Setup(s => s.GetUsersAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns?, CancellationToken>((_, _, _, ct) => + { + var localTcs = new TaskCompletionSource>(); + ct.Register(() => localTcs.TrySetCanceled(ct)); + return localTcs.Task; + }); + + var vm = new UserAccessAuditViewModel( + new Mock().Object, + new Mock().Object, + new Mock().Object, + NullLogger.Instance, + graphUserDirectoryService: mockDir.Object); + vm._currentProfile = new TenantProfile + { + Name = "Test", + TenantUrl = "https://contoso.sharepoint.com", + ClientId = "test-client-id" + }; + + // Start load (will block on the mock) + var loadTask = vm.TestLoadDirectoryAsync(); + + // Cancel + vm.CancelDirectoryLoadCommand.Execute(null); + + await loadTask; + + Assert.Equal("Load cancelled.", vm.DirectoryLoadStatus); + Assert.False(vm.IsLoadingDirectory); + } + + // ── Test 8: IncludeGuests=false filters out Guest users ────────────────── + + [Fact] + public void IncludeGuests_false_filters_out_guest_users() + { + var (vm, _, _) = CreateViewModel(); + vm.DirectoryUsers.Add(MakeMember("Alice")); + vm.DirectoryUsers.Add(MakeGuest("Bob External")); + vm.DirectoryUsers.Add(MakeMember("Charlie")); + + vm.IncludeGuests = false; + + var visible = vm.DirectoryUsersView.Cast().ToList(); + Assert.Equal(2, visible.Count); + Assert.All(visible, u => Assert.Equal("Member", u.UserType)); + } + + // ── Test 9: IncludeGuests=true shows all users ─────────────────────────── + + [Fact] + public void IncludeGuests_true_shows_all_users() + { + var (vm, _, _) = CreateViewModel(); + vm.DirectoryUsers.Add(MakeMember("Alice")); + vm.DirectoryUsers.Add(MakeGuest("Bob External")); + + vm.IncludeGuests = true; + + var visible = vm.DirectoryUsersView.Cast().ToList(); + Assert.Equal(2, visible.Count); + } + + // ── Test 10: DirectoryFilterText filters by DisplayName ────────────────── + + [Fact] + public void DirectoryFilterText_filters_by_DisplayName() + { + var (vm, _, _) = CreateViewModel(); + vm.DirectoryUsers.Add(MakeMember("Alice")); + vm.DirectoryUsers.Add(MakeMember("Charlie")); + vm.IncludeGuests = true; + + vm.DirectoryFilterText = "Ali"; + + var visible = vm.DirectoryUsersView.Cast().ToList(); + Assert.Single(visible); + Assert.Equal("Alice", visible[0].DisplayName); + } + + // ── Test 11: DirectoryFilterText filters by Department ─────────────────── + + [Fact] + public void DirectoryFilterText_filters_by_Department() + { + var (vm, _, _) = CreateViewModel(); + vm.DirectoryUsers.Add(MakeMember("Alice", dept: "Engineering")); + vm.DirectoryUsers.Add(MakeMember("Charlie", dept: "Marketing")); + vm.IncludeGuests = true; + + vm.DirectoryFilterText = "Market"; + + var visible = vm.DirectoryUsersView.Cast().ToList(); + Assert.Single(visible); + Assert.Equal("Charlie", visible[0].DisplayName); + } + + // ── Test 12: DirectoryUsersView default sort is DisplayName ascending ──── + + [Fact] + public void DirectoryUsersView_sorted_by_DisplayName_ascending() + { + var (vm, _, _) = CreateViewModel(); + vm.DirectoryUsers.Add(MakeMember("Charlie")); + vm.DirectoryUsers.Add(MakeMember("Alice")); + vm.DirectoryUsers.Add(MakeMember("Bob")); + vm.IncludeGuests = true; + + var visible = vm.DirectoryUsersView.Cast().ToList(); + Assert.Equal("Alice", visible[0].DisplayName); + Assert.Equal("Bob", visible[1].DisplayName); + Assert.Equal("Charlie", visible[2].DisplayName); + } + + // ── Test 13: OnTenantSwitched clears directory state ───────────────────── + + [Fact] + public async Task OnTenantSwitched_clears_directory_state() + { + var users = new List { MakeMember("Alice") }; + var (vm, _, _) = CreateViewModel(users); + + // Load directory + await vm.TestLoadDirectoryAsync(); + Assert.NotEmpty(vm.DirectoryUsers); + vm.IsBrowseMode = true; + vm.DirectoryFilterText = "test"; + vm.IncludeGuests = true; + + // Act: switch tenant + var newProfile = new TenantProfile + { + Name = "NewTenant", + TenantUrl = "https://newtenant.sharepoint.com", + ClientId = "new-client-id" + }; + WeakReferenceMessenger.Default.Send(new Core.Messages.TenantSwitchedMessage(newProfile)); + + // Assert + Assert.Empty(vm.DirectoryUsers); + Assert.False(vm.IsBrowseMode); + Assert.Empty(vm.DirectoryFilterText); + Assert.Empty(vm.DirectoryLoadStatus); + Assert.False(vm.IsLoadingDirectory); + Assert.False(vm.IncludeGuests); + } + + // ── Test 14: DirectoryUserCount reflects filtered count ─────────────────── + + [Fact] + public void DirectoryUserCount_reflects_filtered_count() + { + var (vm, _, _) = CreateViewModel(); + vm.DirectoryUsers.Add(MakeMember("Alice")); + vm.DirectoryUsers.Add(MakeGuest("Bob External")); + vm.DirectoryUsers.Add(MakeMember("Charlie")); + + // With guests hidden (default IncludeGuests=false) + vm.IncludeGuests = false; + Assert.Equal(2, vm.DirectoryUserCount); + + // With guests shown + vm.IncludeGuests = true; + Assert.Equal(3, vm.DirectoryUserCount); + + // With text filter + vm.DirectoryFilterText = "Ali"; + Assert.Equal(1, vm.DirectoryUserCount); + } + + // ── Test 15: Search mode still works (no regression) ───────────────────── + + [Fact] + public void Search_mode_SelectedUsers_still_works() + { + var (vm, _, _) = CreateViewModel(); + + // Search mode properties should be functional + Assert.Empty(vm.SelectedUsers); + vm.SelectedUsers.Add(new GraphUserResult("Alice Smith", "alice@contoso.com", "alice@contoso.com")); + Assert.Single(vm.SelectedUsers); + Assert.Equal("1 user(s) selected", vm.SelectedUsersLabel); + } + + // ── Test 16: DirectoryFilterText filters by JobTitle ───────────────────── + + [Fact] + public void DirectoryFilterText_filters_by_JobTitle() + { + var (vm, _, _) = CreateViewModel(); + vm.DirectoryUsers.Add(MakeMember("Alice", jobTitle: "Senior Developer")); + vm.DirectoryUsers.Add(MakeMember("Charlie", jobTitle: "Product Manager")); + vm.IncludeGuests = true; + + vm.DirectoryFilterText = "Developer"; + + var visible = vm.DirectoryUsersView.Cast().ToList(); + Assert.Single(visible); + Assert.Equal("Alice", visible[0].DisplayName); + } +} diff --git a/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs b/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs index 4218885..a0a331f 100644 --- a/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs @@ -26,6 +26,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase private readonly UserAccessCsvExportService? _csvExportService; private readonly UserAccessHtmlExportService? _htmlExportService; private readonly IBrandingService? _brandingService; + private readonly IGraphUserDirectoryService? _graphUserDirectoryService; private readonly ILogger _logger; // ── People picker debounce ────────────────────────────────────────────── @@ -74,6 +75,34 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase [ObservableProperty] private bool _isSearching; + // ── Directory browse mode properties ─────────────────────────────────── + + /// When true, the UI shows the directory browse panel instead of the people-picker search. + [ObservableProperty] + private bool _isBrowseMode; + + /// All directory users loaded from Graph. + [ObservableProperty] + private ObservableCollection _directoryUsers = new(); + + /// True while a directory load is in progress. + [ObservableProperty] + private bool _isLoadingDirectory; + + /// Status text for directory load progress, e.g. "Loading... 500 users". + [ObservableProperty] + private string _directoryLoadStatus = string.Empty; + + /// When true, guest users are shown in the directory view; when false, only members. + [ObservableProperty] + private bool _includeGuests; + + /// Text filter applied to DirectoryUsersView (DisplayName, UPN, Department, JobTitle). + [ObservableProperty] + private string _directoryFilterText = string.Empty; + + private CancellationTokenSource? _directoryCts = null; + // ── Computed summary properties ───────────────────────────────────────── /// Total number of access entries in current results. @@ -91,17 +120,25 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase ? $"{SelectedUsers.Count} user(s) selected" : string.Empty; + /// Number of users currently visible in the filtered directory view. + public int DirectoryUserCount => DirectoryUsersView?.Cast().Count() ?? 0; + // ── CollectionViewSource (grouping + filtering) ───────────────────────── /// ICollectionView over Results supporting grouping and text filtering. public ICollectionView ResultsView { get; } + /// ICollectionView over DirectoryUsers with member/guest and text filtering, sorted by DisplayName. + public ICollectionView DirectoryUsersView { get; } + // ── Commands ──────────────────────────────────────────────────────────── public IAsyncRelayCommand ExportCsvCommand { get; } public IAsyncRelayCommand ExportHtmlCommand { get; } public RelayCommand AddUserCommand { get; } public RelayCommand RemoveUserCommand { get; } + public IAsyncRelayCommand LoadDirectoryCommand { get; } + public RelayCommand CancelDirectoryLoadCommand { get; } // ── Current tenant profile ────────────────────────────────────────────── @@ -120,6 +157,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase UserAccessCsvExportService csvExportService, UserAccessHtmlExportService htmlExportService, IBrandingService brandingService, + IGraphUserDirectoryService graphUserDirectoryService, ILogger logger) : base(logger) { @@ -129,6 +167,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase _csvExportService = csvExportService; _htmlExportService = htmlExportService; _brandingService = brandingService; + _graphUserDirectoryService = graphUserDirectoryService; _logger = logger; ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); @@ -141,6 +180,17 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase var cvs = new CollectionViewSource { Source = Results }; ResultsView = cvs.View; ApplyGrouping(); + + var dirCvs = new CollectionViewSource { Source = DirectoryUsers }; + DirectoryUsersView = dirCvs.View; + DirectoryUsersView.SortDescriptions.Add( + new SortDescription(nameof(GraphDirectoryUser.DisplayName), ListSortDirection.Ascending)); + DirectoryUsersView.Filter = DirectoryFilterPredicate; + + LoadDirectoryCommand = new AsyncRelayCommand(LoadDirectoryAsync, () => !IsLoadingDirectory); + CancelDirectoryLoadCommand = new RelayCommand( + () => _directoryCts?.Cancel(), + () => IsLoadingDirectory); } /// Test constructor — omits export services (not needed for unit tests). @@ -149,7 +199,8 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase IGraphUserSearchService graphUserSearchService, ISessionManager sessionManager, ILogger logger, - IBrandingService? brandingService = null) + IBrandingService? brandingService = null, + IGraphUserDirectoryService? graphUserDirectoryService = null) : base(logger) { _auditService = auditService; @@ -158,6 +209,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase _csvExportService = null; _htmlExportService = null; _brandingService = brandingService; + _graphUserDirectoryService = graphUserDirectoryService; _logger = logger; ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); @@ -170,6 +222,17 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase var cvs = new CollectionViewSource { Source = Results }; ResultsView = cvs.View; ApplyGrouping(); + + var dirCvs = new CollectionViewSource { Source = DirectoryUsers }; + DirectoryUsersView = dirCvs.View; + DirectoryUsersView.SortDescriptions.Add( + new SortDescription(nameof(GraphDirectoryUser.DisplayName), ListSortDirection.Ascending)); + DirectoryUsersView.Filter = DirectoryFilterPredicate; + + LoadDirectoryCommand = new AsyncRelayCommand(LoadDirectoryAsync, () => !IsLoadingDirectory); + CancelDirectoryLoadCommand = new RelayCommand( + () => _directoryCts?.Cancel(), + () => IsLoadingDirectory); } // ── FeatureViewModelBase implementation ───────────────────────────────── @@ -251,6 +314,18 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase NotifySummaryProperties(); ExportCsvCommand.NotifyCanExecuteChanged(); ExportHtmlCommand.NotifyCanExecuteChanged(); + + // Directory browse mode reset + _directoryCts?.Cancel(); + _directoryCts?.Dispose(); + _directoryCts = null; + DirectoryUsers.Clear(); + DirectoryFilterText = string.Empty; + DirectoryLoadStatus = string.Empty; + IsBrowseMode = false; + IsLoadingDirectory = false; + IncludeGuests = false; + OnPropertyChanged(nameof(DirectoryUserCount)); } // ── Observable property change handlers ───────────────────────────────── @@ -285,6 +360,24 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase NotifySummaryProperties(); } + partial void OnIncludeGuestsChanged(bool value) + { + DirectoryUsersView.Refresh(); + OnPropertyChanged(nameof(DirectoryUserCount)); + } + + partial void OnDirectoryFilterTextChanged(string value) + { + DirectoryUsersView.Refresh(); + OnPropertyChanged(nameof(DirectoryUserCount)); + } + + partial void OnIsLoadingDirectoryChanged(bool value) + { + LoadDirectoryCommand.NotifyCanExecuteChanged(); + CancelDirectoryLoadCommand.NotifyCanExecuteChanged(); + } + // ── Internal helpers ───────────────────────────────────────────────────── /// Sets the current tenant profile (for test injection). @@ -294,6 +387,91 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase internal Task TestRunOperationAsync(CancellationToken ct, IProgress progress) => RunOperationAsync(ct, progress); + // ── Directory browse mode ────────────────────────────────────────────── + + private async Task LoadDirectoryAsync() + { + if (_graphUserDirectoryService is null) return; + var clientId = _currentProfile?.ClientId; + if (string.IsNullOrEmpty(clientId)) + { + StatusMessage = "No tenant profile selected. Please connect first."; + return; + } + + _directoryCts?.Cancel(); + _directoryCts?.Dispose(); + _directoryCts = new CancellationTokenSource(); + var ct = _directoryCts.Token; + + IsLoadingDirectory = true; + DirectoryLoadStatus = "Loading..."; + try + { + var progress = new Progress(count => + DirectoryLoadStatus = $"Loading... {count} users"); + + var users = await _graphUserDirectoryService.GetUsersAsync( + clientId, includeGuests: true, progress, ct); + + ct.ThrowIfCancellationRequested(); + + var dispatcher = Application.Current?.Dispatcher; + if (dispatcher != null) + { + await dispatcher.InvokeAsync(() => PopulateDirectory(users)); + } + else + { + PopulateDirectory(users); + } + + DirectoryLoadStatus = $"{users.Count} users loaded"; + } + catch (OperationCanceledException) + { + DirectoryLoadStatus = "Load cancelled."; + } + catch (Exception ex) + { + DirectoryLoadStatus = $"Failed: {ex.Message}"; + _logger.LogError(ex, "Directory load failed."); + } + finally + { + IsLoadingDirectory = false; + } + } + + private void PopulateDirectory(IReadOnlyList users) + { + DirectoryUsers.Clear(); + foreach (var u in users) + DirectoryUsers.Add(u); + DirectoryUsersView.Refresh(); + OnPropertyChanged(nameof(DirectoryUserCount)); + } + + private bool DirectoryFilterPredicate(object obj) + { + if (obj is not GraphDirectoryUser user) return false; + + // Member/guest filter + if (!IncludeGuests && !string.Equals(user.UserType, "Member", StringComparison.OrdinalIgnoreCase)) + return false; + + // Text filter + if (string.IsNullOrWhiteSpace(DirectoryFilterText)) return true; + var filter = DirectoryFilterText.Trim(); + return user.DisplayName.Contains(filter, StringComparison.OrdinalIgnoreCase) + || user.UserPrincipalName.Contains(filter, StringComparison.OrdinalIgnoreCase) + || (user.Department?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false) + || (user.JobTitle?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false); + } + + /// Exposes LoadDirectoryAsync for unit tests (internal + InternalsVisibleTo). + internal Task TestLoadDirectoryAsync() => LoadDirectoryAsync(); + // ── Command implementations ─────────────────────────────────────────────── private bool CanExport() => Results.Count > 0;