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.");
+ }
+}