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