--- phase: 10-branding-data-foundation plan: 02 type: execute wave: 1 depends_on: [] files_modified: - SharepointToolbox/Core/Models/GraphDirectoryUser.cs - SharepointToolbox/Services/IGraphUserDirectoryService.cs - SharepointToolbox/Services/GraphUserDirectoryService.cs - SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs autonomous: true requirements: - BRAND-06 must_haves: truths: - "GetUsersAsync returns all enabled member users following @odata.nextLink until exhausted" - "GetUsersAsync respects CancellationToken and stops iteration when cancelled" - "Each returned user includes DisplayName, UserPrincipalName, Mail, Department, and JobTitle" artifacts: - path: "SharepointToolbox/Core/Models/GraphDirectoryUser.cs" provides: "Result record for directory enumeration" contains: "record GraphDirectoryUser" - path: "SharepointToolbox/Services/IGraphUserDirectoryService.cs" provides: "Interface for directory enumeration" exports: ["GetUsersAsync"] - path: "SharepointToolbox/Services/GraphUserDirectoryService.cs" provides: "PageIterator-based Graph user enumeration" contains: "PageIterator" - path: "SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs" provides: "Unit tests for directory service" min_lines: 40 key_links: - from: "SharepointToolbox/Services/GraphUserDirectoryService.cs" to: "SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs" via: "constructor injection" pattern: "GraphClientFactory" - from: "SharepointToolbox/Services/GraphUserDirectoryService.cs" to: "Microsoft.Graph PageIterator" via: "SDK pagination" pattern: "PageIterator" --- Create the Graph user directory service for paginated tenant user enumeration. Purpose: Phase 13 (User Directory ViewModel) needs a service that enumerates all enabled member users from a tenant via Microsoft Graph with pagination. This plan builds the infrastructure service and its tests. Output: GraphDirectoryUser model, IGraphUserDirectoryService interface, GraphUserDirectoryService implementation with PageIterator, and unit tests. @C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md @C:/Users/dev/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/10-branding-data-foundation/10-RESEARCH.md From SharepointToolbox/Services/IGraphUserSearchService.cs: ```csharp namespace SharepointToolbox.Services; public interface IGraphUserSearchService { Task> SearchUsersAsync( string clientId, string query, int maxResults = 10, CancellationToken ct = default); } public record GraphUserResult(string DisplayName, string UserPrincipalName, string? Mail); ``` From SharepointToolbox/Services/GraphUserSearchService.cs: ```csharp public class GraphUserSearchService : IGraphUserSearchService { private readonly GraphClientFactory _graphClientFactory; public GraphUserSearchService(GraphClientFactory graphClientFactory) { _graphClientFactory = graphClientFactory; } public async Task> SearchUsersAsync( string clientId, string query, int maxResults = 10, CancellationToken ct = default) { var graphClient = await _graphClientFactory.CreateClientAsync(clientId, ct); var response = await graphClient.Users.GetAsync(config => { config.QueryParameters.Filter = $"startsWith(displayName,'{escapedQuery}')..."; config.QueryParameters.Select = new[] { "displayName", "userPrincipalName", "mail" }; config.QueryParameters.Top = maxResults; config.Headers.Add("ConsistencyLevel", "eventual"); config.QueryParameters.Count = true; }, ct); // ...map response.Value to GraphUserResult list } } ``` From SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs: ```csharp public class GraphClientFactory { private readonly MsalClientFactory _msalFactory; public GraphClientFactory(MsalClientFactory msalFactory) { _msalFactory = msalFactory; } public async Task CreateClientAsync(string clientId, CancellationToken ct) { /* ... */ } } ``` Task 1: Create GraphDirectoryUser model and IGraphUserDirectoryService interface SharepointToolbox/Core/Models/GraphDirectoryUser.cs, SharepointToolbox/Services/IGraphUserDirectoryService.cs - GraphDirectoryUser is a positional record with DisplayName (string), UserPrincipalName (string), Mail (string?), Department (string?), JobTitle (string?) - IGraphUserDirectoryService declares GetUsersAsync(string clientId, IProgress<int>? progress, CancellationToken ct) returning Task<IReadOnlyList<GraphDirectoryUser>> 1. Create `GraphDirectoryUser.cs` in `Core/Models/`: ```csharp namespace SharepointToolbox.Core.Models; public record GraphDirectoryUser( string DisplayName, string UserPrincipalName, string? Mail, string? Department, string? JobTitle); ``` This is a positional record (fine here since it's never JSON-deserialized — it's only constructed in code from Graph SDK User objects). 2. Create `IGraphUserDirectoryService.cs` in `Services/`: ```csharp namespace SharepointToolbox.Services; public interface IGraphUserDirectoryService { Task> GetUsersAsync( string clientId, IProgress? progress = null, CancellationToken ct = default); } ``` The `IProgress` parameter reports the running count of users fetched so far — Phase 13's ViewModel will use this to show "Loading... X users" feedback. It's optional (null = no reporting). dotnet build --no-restore -warnaserror GraphDirectoryUser record and IGraphUserDirectoryService interface exist and compile without warnings. Task 2: Implement GraphUserDirectoryService with PageIterator and tests SharepointToolbox/Services/GraphUserDirectoryService.cs, SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs - Test 1: GetUsersAsync with mocked GraphClientFactory returns mapped GraphDirectoryUser records with all 5 fields - Test 2: GetUsersAsync reports progress via IProgress<int> with incrementing user count - Test 3: GetUsersAsync with cancelled token throws OperationCanceledException or returns partial results 1. Create `GraphUserDirectoryService.cs`: - Constructor takes `GraphClientFactory` (same pattern as `GraphUserSearchService`). - `GetUsersAsync` implementation: a. Get `GraphServiceClient` via `_graphClientFactory.CreateClientAsync(clientId, ct)`. b. Call `graphClient.Users.GetAsync(config => { ... }, ct)` with: - `config.QueryParameters.Filter = "accountEnabled eq true and userType eq 'Member'"` — standard equality filter, does NOT require ConsistencyLevel: eventual (unlike GraphUserSearchService which uses startsWith). Do NOT add ConsistencyLevel header. Do NOT add $count. - `config.QueryParameters.Select = new[] { "displayName", "userPrincipalName", "mail", "department", "jobTitle" }` - `config.QueryParameters.Top = 999` c. If response is null, return empty list. d. Create `PageIterator.CreatePageIterator(graphClient, response, callback)`. e. In the callback: - Check `ct.IsCancellationRequested` — if true, `return false` to stop iteration (see RESEARCH Pitfall 2). - Map User to GraphDirectoryUser: `new GraphDirectoryUser(user.DisplayName ?? user.UserPrincipalName ?? string.Empty, user.UserPrincipalName ?? string.Empty, user.Mail, user.Department, user.JobTitle)`. - Add to results list. - Report progress: `progress?.Report(results.Count)`. - Return true to continue. f. Call `await pageIterator.IterateAsync(ct)`. g. Return results as `IReadOnlyList`. - Add a comment on the filter line: `// Pending real-tenant verification — see STATE.md pending todos` 2. Create `GraphUserDirectoryServiceTests.cs`: - Use `[Trait("Category", "Unit")]`. - Testing PageIterator with mocks is complex because `PageIterator` requires a real `GraphServiceClient`. Instead, test at a higher level: a. Create a mock `GraphClientFactory` using Moq that returns a mock `GraphServiceClient`. b. For the basic mapping test: mock `graphClient.Users.GetAsync()` to return a `UserCollectionResponse` with a list of test `User` objects (no `@odata.nextLink` = single page). Verify the returned `GraphDirectoryUser` list has correct field mapping. c. For the progress test: same setup, verify `IProgress.Report` is called with incrementing counts. d. For cancellation: use a pre-cancelled `CancellationTokenSource`. The `GetAsync` call should throw `OperationCanceledException` or the callback should detect cancellation. - If mocking `GraphServiceClient.Users.GetAsync` proves too complex with the Graph SDK's request builder pattern, mark the test with `[Fact(Skip = "Requires integration test with real Graph client")]` and add a comment explaining why. The critical thing is the test FILE exists with the intent documented. - Focus on what IS testable without a real Graph endpoint: the mapping logic. Consider extracting a static `MapUser(User user)` method and testing that directly. dotnet test --filter "FullyQualifiedName~GraphUserDirectoryServiceTests" --no-build GraphUserDirectoryService exists with PageIterator pagination, cancellation support via callback check, progress reporting, and correct filter (no ConsistencyLevel). Tests verify mapping logic and exist for pagination/cancellation scenarios. ```bash dotnet build --no-restore -warnaserror dotnet test --filter "FullyQualifiedName~GraphUserDirectoryService" --no-build ``` Both commands must succeed. No warnings, no test failures. - GraphDirectoryUser record has all 5 fields (DisplayName, UPN, Mail, Department, JobTitle) - IGraphUserDirectoryService interface declares GetUsersAsync with clientId, progress, and cancellation - GraphUserDirectoryService uses PageIterator for pagination, checks cancellation in callback, reports progress - Filter is "accountEnabled eq true and userType eq 'Member'" WITHOUT ConsistencyLevel header - Tests exist and pass for mapping logic; pagination/cancellation tests are either passing or skipped with clear justification After completion, create `.planning/phases/10-branding-data-foundation/10-02-SUMMARY.md`