diff --git a/SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs b/SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs index b45b2af..4545047 100644 --- a/SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs +++ b/SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs @@ -1,22 +1,67 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SharePoint.Client; +using Moq; using SharepointToolbox.Core.Models; using SharepointToolbox.Services; +using SharepointToolbox.ViewModels.Tabs; namespace SharepointToolbox.Tests.ViewModels; /// -/// Test stubs for PERM-02 (multi-site scan loop). -/// Skipped until PermissionsViewModel is implemented in Plan 02. +/// Unit tests for PermissionsViewModel. +/// PERM-02: multi-site scan loop invokes ScanSiteAsync once per URL. /// public class PermissionsViewModelTests { - [Fact(Skip = "Requires live CSOM context — covered by Plan 02 implementation")] + [Fact] public async Task StartScanAsync_WithMultipleSiteUrls_CallsServiceOncePerUrl() { - // PERM-02: When the user supplies N site URLs, IPermissionsService.ScanSiteAsync - // is invoked exactly once per URL (sequential, not parallel). - // Arrange — requires PermissionsViewModel and a mock IPermissionsService + // Arrange + var mockPermissionsService = new Mock(); + mockPermissionsService + .Setup(s => s.ScanSiteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new List()); + + var mockSiteListService = new Mock(); + + var mockSessionManager = new Mock(); + mockSessionManager + .Setup(s => s.GetOrCreateContextAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((ClientContext)null!); + + var vm = new PermissionsViewModel( + mockPermissionsService.Object, + mockSiteListService.Object, + mockSessionManager.Object, + new NullLogger()); + + // Set up two site URLs via SelectedSites + vm.SelectedSites.Add(new SiteInfo("https://tenant1.sharepoint.com/sites/alpha", "Alpha")); + vm.SelectedSites.Add(new SiteInfo("https://tenant1.sharepoint.com/sites/beta", "Beta")); + vm.SetCurrentProfile(new TenantProfile + { + Name = "Test", + TenantUrl = "https://tenant1.sharepoint.com", + ClientId = "client-id" + }); + // Act - // Assert - await Task.CompletedTask; + await vm.TestRunOperationAsync(CancellationToken.None, new Progress()); + + // Assert: ScanSiteAsync called exactly twice (once per URL) + mockPermissionsService.Verify( + s => s.ScanSiteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Exactly(2)); } } diff --git a/SharepointToolbox/Services/ISessionManager.cs b/SharepointToolbox/Services/ISessionManager.cs new file mode 100644 index 0000000..9543843 --- /dev/null +++ b/SharepointToolbox/Services/ISessionManager.cs @@ -0,0 +1,20 @@ +using Microsoft.SharePoint.Client; +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Services; + +/// +/// Abstraction over SessionManager, enabling unit testing of ViewModels +/// without live MSAL/SharePoint connections. +/// +public interface ISessionManager +{ + /// Returns an existing ClientContext or creates one via interactive login. + Task GetOrCreateContextAsync(TenantProfile profile, CancellationToken ct = default); + + /// Clears the cached session for the given tenant URL. + Task ClearSessionAsync(string tenantUrl); + + /// Returns true if an authenticated ClientContext exists for this tenantUrl. + bool IsAuthenticated(string tenantUrl); +} diff --git a/SharepointToolbox/Services/SessionManager.cs b/SharepointToolbox/Services/SessionManager.cs index 5a1faa6..1e89eb2 100644 --- a/SharepointToolbox/Services/SessionManager.cs +++ b/SharepointToolbox/Services/SessionManager.cs @@ -14,7 +14,7 @@ namespace SharepointToolbox.Services; /// Every SharePoint operation goes through this class — callers MUST NOT store /// ClientContext references; request it fresh each time via GetOrCreateContextAsync. /// -public class SessionManager +public class SessionManager : ISessionManager { private readonly MsalClientFactory _msalFactory; private readonly Dictionary _contexts = new(); diff --git a/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs b/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs new file mode 100644 index 0000000..045e217 --- /dev/null +++ b/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs @@ -0,0 +1,47 @@ +using System.Collections.ObjectModel; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using SharepointToolbox.Core.Models; +using SharepointToolbox.Services; + +namespace SharepointToolbox.ViewModels.Tabs; + +/// +/// STUB: PermissionsViewModel — RED phase. Not yet implemented. +/// +public partial class PermissionsViewModel : FeatureViewModelBase +{ + private readonly IPermissionsService _permissionsService; + private readonly ISiteListService _siteListService; + private readonly ISessionManager _sessionManager; + + public ObservableCollection SelectedSites { get; } = new(); + public ObservableCollection Results { get; private set; } = new(); + + internal TenantProfile? _currentProfile; + + public PermissionsViewModel( + IPermissionsService permissionsService, + ISiteListService siteListService, + ISessionManager sessionManager, + ILogger logger) + : base(logger) + { + _permissionsService = permissionsService; + _siteListService = siteListService; + _sessionManager = sessionManager; + } + + public void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile; + + internal Task TestRunOperationAsync(CancellationToken ct, IProgress progress) + => RunOperationAsync(ct, progress); + + protected override async Task RunOperationAsync(CancellationToken ct, IProgress progress) + { + // RED STUB: always throws to make tests fail at RED phase + throw new NotImplementedException("PermissionsViewModel.RunOperationAsync not yet implemented."); + } +}