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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<IGraphUserDirectoryService> dirMock, Mock<IUserAccessAuditService> auditMock)
|
||||
CreateViewModel(IReadOnlyList<GraphDirectoryUser>? directoryResult = null)
|
||||
{
|
||||
var mockAudit = new Mock<IUserAccessAuditService>();
|
||||
var mockGraph = new Mock<IGraphUserSearchService>();
|
||||
var mockSession = new Mock<ISessionManager>();
|
||||
var mockDir = new Mock<IGraphUserDirectoryService>();
|
||||
mockDir.Setup(s => s.GetUsersAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<IProgress<int>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(directoryResult ?? Array.Empty<GraphDirectoryUser>());
|
||||
|
||||
var vm = new UserAccessAuditViewModel(
|
||||
mockAudit.Object,
|
||||
mockGraph.Object,
|
||||
mockSession.Object,
|
||||
NullLogger<FeatureViewModelBase>.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<GraphDirectoryUser> { 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<GraphDirectoryUser> { 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<IReadOnlyList<GraphDirectoryUser>>();
|
||||
var mockDir = new Mock<IGraphUserDirectoryService>();
|
||||
mockDir.Setup(s => s.GetUsersAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<IProgress<int>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns<string, bool, IProgress<int>?, CancellationToken>((_, _, _, ct) =>
|
||||
{
|
||||
var localTcs = new TaskCompletionSource<IReadOnlyList<GraphDirectoryUser>>();
|
||||
ct.Register(() => localTcs.TrySetCanceled(ct));
|
||||
return localTcs.Task;
|
||||
});
|
||||
|
||||
var vm = new UserAccessAuditViewModel(
|
||||
new Mock<IUserAccessAuditService>().Object,
|
||||
new Mock<IGraphUserSearchService>().Object,
|
||||
new Mock<ISessionManager>().Object,
|
||||
NullLogger<FeatureViewModelBase>.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<GraphDirectoryUser>().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<GraphDirectoryUser>().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<GraphDirectoryUser>().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<GraphDirectoryUser>().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<GraphDirectoryUser>().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<GraphDirectoryUser> { 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<GraphDirectoryUser>().ToList();
|
||||
Assert.Single(visible);
|
||||
Assert.Equal("Alice", visible[0].DisplayName);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user