feat(10-02): implement GraphUserDirectoryService with PageIterator and unit tests
- GraphUserDirectoryService uses PageIterator<User, UserCollectionResponse> for pagination - Filter: accountEnabled eq true and userType eq 'Member' (no ConsistencyLevel header) - Cancellation checked in PageIterator callback (return false stops iteration) - Progress reported via IProgress<int> with running count per user - MapUser extracted as internal static for direct unit test coverage - Tests: 5 unit tests for MapUser field mapping and fallback logic - Integration-level tests (pagination/cancellation) skipped with rationale documented - Note: test project compilation blocked by pre-existing BrandingServiceTests.cs (10-01 artifact)
This commit is contained in:
@@ -0,0 +1,150 @@
|
|||||||
|
using Microsoft.Graph.Models;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Services;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Tests.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for <see cref="GraphUserDirectoryService"/> (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.
|
||||||
|
/// </summary>
|
||||||
|
[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<int>.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;
|
||||||
|
}
|
||||||
78
SharepointToolbox/Services/GraphUserDirectoryService.cs
Normal file
78
SharepointToolbox/Services/GraphUserDirectoryService.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
using Microsoft.Graph;
|
||||||
|
using Microsoft.Graph.Models;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using AppGraphClientFactory = SharepointToolbox.Infrastructure.Auth.GraphClientFactory;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class GraphUserDirectoryService : IGraphUserDirectoryService
|
||||||
|
{
|
||||||
|
private readonly AppGraphClientFactory _graphClientFactory;
|
||||||
|
|
||||||
|
public GraphUserDirectoryService(AppGraphClientFactory graphClientFactory)
|
||||||
|
{
|
||||||
|
_graphClientFactory = graphClientFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<GraphDirectoryUser>> GetUsersAsync(
|
||||||
|
string clientId,
|
||||||
|
IProgress<int>? 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<GraphDirectoryUser>();
|
||||||
|
|
||||||
|
var results = new List<GraphDirectoryUser>();
|
||||||
|
|
||||||
|
var pageIterator = PageIterator<User, UserCollectionResponse>.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a Graph SDK <see cref="User"/> object to a <see cref="GraphDirectoryUser"/> record.
|
||||||
|
/// Extracted as an internal static method to allow direct unit-test coverage of mapping logic
|
||||||
|
/// without requiring a live Graph endpoint.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user