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:
Dev
2026-04-08 12:32:04 +02:00
parent 2280f12eab
commit 3ba574612f
2 changed files with 228 additions and 0 deletions

View 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);
}