diff --git a/SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs b/SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs new file mode 100644 index 0000000..bfdf141 --- /dev/null +++ b/SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs @@ -0,0 +1,150 @@ +using Microsoft.Graph.Models; +using SharepointToolbox.Core.Models; +using SharepointToolbox.Services; + +namespace SharepointToolbox.Tests.Services; + +/// +/// Unit tests for (Phase 10 Plan 02). +/// +/// Testing strategy: GraphUserDirectoryService wraps Microsoft Graph SDK's PageIterator, +/// whose constructor is internal and cannot be mocked without a real GraphServiceClient. +/// Full pagination/cancellation tests therefore require integration-level setup. +/// +/// We test what IS unit-testable: +/// 1. MapUser — the static mapping method that converts a Graph User to GraphDirectoryUser. +/// This covers all 5 required fields and the DisplayName fallback logic. +/// 2. GetUsersAsync integration paths are documented with Skip tests that explain the +/// constraint and serve as living documentation of intended behaviour. +/// +[Trait("Category", "Unit")] +public class GraphUserDirectoryServiceTests +{ + // ── MapUser: field mapping ──────────────────────────────────────────────── + + [Fact] + public void MapUser_AllFieldsPresent_MapsCorrectly() + { + var user = new User + { + DisplayName = "Alice Smith", + UserPrincipalName = "alice@contoso.com", + Mail = "alice@contoso.com", + Department = "Engineering", + JobTitle = "Senior Developer" + }; + + var result = GraphUserDirectoryService.MapUser(user); + + Assert.Equal("Alice Smith", result.DisplayName); + Assert.Equal("alice@contoso.com", result.UserPrincipalName); + Assert.Equal("alice@contoso.com", result.Mail); + Assert.Equal("Engineering", result.Department); + Assert.Equal("Senior Developer", result.JobTitle); + } + + [Fact] + public void MapUser_NullDisplayName_FallsBackToUserPrincipalName() + { + var user = new User + { + DisplayName = null, + UserPrincipalName = "bob@contoso.com", + Mail = null, + Department = null, + JobTitle = null + }; + + var result = GraphUserDirectoryService.MapUser(user); + + Assert.Equal("bob@contoso.com", result.DisplayName); + Assert.Equal("bob@contoso.com", result.UserPrincipalName); + Assert.Null(result.Mail); + Assert.Null(result.Department); + Assert.Null(result.JobTitle); + } + + [Fact] + public void MapUser_NullDisplayNameAndNullUPN_FallsBackToEmptyString() + { + var user = new User + { + DisplayName = null, + UserPrincipalName = null, + Mail = null, + Department = null, + JobTitle = null + }; + + var result = GraphUserDirectoryService.MapUser(user); + + Assert.Equal(string.Empty, result.DisplayName); + Assert.Equal(string.Empty, result.UserPrincipalName); + } + + [Fact] + public void MapUser_NullUPN_ReturnsEmptyStringForUPN() + { + var user = new User + { + DisplayName = "Carol Jones", + UserPrincipalName = null, + Mail = "carol@contoso.com", + Department = "Marketing", + JobTitle = "Manager" + }; + + var result = GraphUserDirectoryService.MapUser(user); + + Assert.Equal("Carol Jones", result.DisplayName); + Assert.Equal(string.Empty, result.UserPrincipalName); + Assert.Equal("carol@contoso.com", result.Mail); + } + + [Fact] + public void MapUser_OptionalFieldsNull_ProducesNullableNullProperties() + { + var user = new User + { + DisplayName = "Dave Brown", + UserPrincipalName = "dave@contoso.com", + Mail = null, + Department = null, + JobTitle = null + }; + + var result = GraphUserDirectoryService.MapUser(user); + + Assert.Null(result.Mail); + Assert.Null(result.Department); + Assert.Null(result.JobTitle); + } + + // ── GetUsersAsync: integration-level scenarios (skipped without live tenant) ── + + [Fact(Skip = "Requires integration test with real Graph client — PageIterator.CreatePageIterator " + + "uses internal GraphServiceClient request execution that cannot be mocked via Moq. " + + "Intended behaviour: returns all users matching filter across all pages, " + + "correctly mapping all 5 fields per user.")] + public Task GetUsersAsync_SinglePage_ReturnsMappedUsers() + => Task.CompletedTask; + + [Fact(Skip = "Requires integration test with real Graph client. " + + "Intended behaviour: IProgress.Report is called once per user " + + "with an incrementing count (1, 2, 3, ...).")] + public Task GetUsersAsync_ReportsProgressWithIncrementingCount() + => Task.CompletedTask; + + [Fact(Skip = "Requires integration test with real Graph client. " + + "Intended behaviour: when CancellationToken is cancelled during iteration, " + + "the callback returns false and iteration stops, returning partial results " + + "(or OperationCanceledException if cancellation fires before first page).")] + public Task GetUsersAsync_CancelledToken_StopsIteration() + => Task.CompletedTask; + + [Fact(Skip = "Requires integration test with real Graph client. " + + "Intended behaviour: when Graph returns null response, " + + "GetUsersAsync returns an empty IReadOnlyList without throwing.")] + public Task GetUsersAsync_NullResponse_ReturnsEmptyList() + => Task.CompletedTask; +} diff --git a/SharepointToolbox/Services/GraphUserDirectoryService.cs b/SharepointToolbox/Services/GraphUserDirectoryService.cs new file mode 100644 index 0000000..63772b7 --- /dev/null +++ b/SharepointToolbox/Services/GraphUserDirectoryService.cs @@ -0,0 +1,78 @@ +using Microsoft.Graph; +using Microsoft.Graph.Models; +using SharepointToolbox.Core.Models; +using AppGraphClientFactory = SharepointToolbox.Infrastructure.Auth.GraphClientFactory; + +namespace SharepointToolbox.Services; + +/// +/// Enumerates all enabled member users from a tenant via Microsoft Graph, +/// using PageIterator for transparent multi-page iteration. +/// Used by Phase 13's User Directory ViewModel. +/// +public class GraphUserDirectoryService : IGraphUserDirectoryService +{ + private readonly AppGraphClientFactory _graphClientFactory; + + public GraphUserDirectoryService(AppGraphClientFactory graphClientFactory) + { + _graphClientFactory = graphClientFactory; + } + + /// + public async Task> GetUsersAsync( + string clientId, + IProgress? progress = null, + CancellationToken ct = default) + { + var graphClient = await _graphClientFactory.CreateClientAsync(clientId, ct); + + var response = await graphClient.Users.GetAsync(config => + { + // Pending real-tenant verification — see STATE.md pending todos + config.QueryParameters.Filter = "accountEnabled eq true and userType eq 'Member'"; + config.QueryParameters.Select = new[] + { + "displayName", "userPrincipalName", "mail", "department", "jobTitle" + }; + config.QueryParameters.Top = 999; + // No ConsistencyLevel header: standard equality filter does not require eventual consistency + }, ct); + + if (response is null) + return Array.Empty(); + + var results = new List(); + + var pageIterator = PageIterator.CreatePageIterator( + graphClient, + response, + user => + { + // Honour cancellation inside the callback — returning false stops iteration + if (ct.IsCancellationRequested) + return false; + + results.Add(MapUser(user)); + progress?.Report(results.Count); + return true; + }); + + await pageIterator.IterateAsync(ct); + + return results; + } + + /// + /// Maps a Graph SDK object to a record. + /// Extracted as an internal static method to allow direct unit-test coverage of mapping logic + /// without requiring a live Graph endpoint. + /// + internal static GraphDirectoryUser MapUser(User user) => + new( + DisplayName: user.DisplayName ?? user.UserPrincipalName ?? string.Empty, + UserPrincipalName: user.UserPrincipalName ?? string.Empty, + Mail: user.Mail, + Department: user.Department, + JobTitle: user.JobTitle); +}