236 lines
11 KiB
Markdown
236 lines
11 KiB
Markdown
---
|
|
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<User, UserCollectionResponse>"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md
|
|
@C:/Users/dev/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/10-branding-data-foundation/10-RESEARCH.md
|
|
|
|
<interfaces>
|
|
<!-- Existing Graph service pattern to follow. -->
|
|
|
|
From SharepointToolbox/Services/IGraphUserSearchService.cs:
|
|
```csharp
|
|
namespace SharepointToolbox.Services;
|
|
|
|
public interface IGraphUserSearchService
|
|
{
|
|
Task<IReadOnlyList<GraphUserResult>> 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<IReadOnlyList<GraphUserResult>> 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<GraphServiceClient> CreateClientAsync(string clientId, CancellationToken ct) { /* ... */ }
|
|
}
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Create GraphDirectoryUser model and IGraphUserDirectoryService interface</name>
|
|
<files>
|
|
SharepointToolbox/Core/Models/GraphDirectoryUser.cs,
|
|
SharepointToolbox/Services/IGraphUserDirectoryService.cs
|
|
</files>
|
|
<behavior>
|
|
- 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>>
|
|
</behavior>
|
|
<action>
|
|
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<IReadOnlyList<GraphDirectoryUser>> GetUsersAsync(
|
|
string clientId,
|
|
IProgress<int>? progress = null,
|
|
CancellationToken ct = default);
|
|
}
|
|
```
|
|
The `IProgress<int>` 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).
|
|
</action>
|
|
<verify>
|
|
<automated>dotnet build --no-restore -warnaserror</automated>
|
|
</verify>
|
|
<done>GraphDirectoryUser record and IGraphUserDirectoryService interface exist and compile without warnings.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Implement GraphUserDirectoryService with PageIterator and tests</name>
|
|
<files>
|
|
SharepointToolbox/Services/GraphUserDirectoryService.cs,
|
|
SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs
|
|
</files>
|
|
<behavior>
|
|
- 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
|
|
</behavior>
|
|
<action>
|
|
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<User, UserCollectionResponse>.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<GraphDirectoryUser>`.
|
|
- 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<int>.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.
|
|
</action>
|
|
<verify>
|
|
<automated>dotnet test --filter "FullyQualifiedName~GraphUserDirectoryServiceTests" --no-build</automated>
|
|
</verify>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
```bash
|
|
dotnet build --no-restore -warnaserror
|
|
dotnet test --filter "FullyQualifiedName~GraphUserDirectoryService" --no-build
|
|
```
|
|
Both commands must succeed. No warnings, no test failures.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- 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
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/10-branding-data-foundation/10-02-SUMMARY.md`
|
|
</output>
|