diff --git a/SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs b/SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs
new file mode 100644
index 0000000..498c68d
--- /dev/null
+++ b/SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs
@@ -0,0 +1,167 @@
+using SharepointToolbox.Services;
+
+namespace SharepointToolbox.Tests.Services;
+
+///
+/// Unit tests for (Phase 17 Plan 01).
+///
+/// Testing strategy:
+/// SharePointGroupResolver wraps CSOM (ClientContext) and Microsoft Graph SDK.
+/// Both require live infrastructure that cannot be mocked without heavy ceremony.
+///
+/// We test what IS unit-testable without live infrastructure:
+/// 1. IsAadGroup — static helper: login prefix pattern detection
+/// 2. ExtractAadGroupId — static helper: GUID extraction from AAD group login
+/// 3. StripClaims — static helper: UPN extraction after last pipe
+/// 4. ResolveGroupsAsync with empty list — returns empty dict (no CSOM calls made)
+///
+/// Integration tests requiring live tenant / CSOM context are skip-marked.
+///
+[Trait("Category", "Unit")]
+public class SharePointGroupResolverTests
+{
+ // ── IsAadGroup ─────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void IsAadGroup_AadGroupLogin_ReturnsTrue()
+ {
+ var login = "c:0t.c|tenant|aaaabbbb-cccc-dddd-eeee-ffffgggghhhh";
+ Assert.True(SharePointGroupResolver.IsAadGroup(login));
+ }
+
+ [Fact]
+ public void IsAadGroup_RegularUserLogin_ReturnsFalse()
+ {
+ var login = "i:0#.f|membership|user@contoso.com";
+ Assert.False(SharePointGroupResolver.IsAadGroup(login));
+ }
+
+ [Fact]
+ public void IsAadGroup_SecurityGroupLogin_ReturnsFalse()
+ {
+ var login = "c:0(.s|true";
+ Assert.False(SharePointGroupResolver.IsAadGroup(login));
+ }
+
+ [Fact]
+ public void IsAadGroup_EmptyString_ReturnsFalse()
+ {
+ Assert.False(SharePointGroupResolver.IsAadGroup(string.Empty));
+ }
+
+ [Fact]
+ public void IsAadGroup_CaseInsensitive_ReturnsTrue()
+ {
+ // Prefix check should be case-insensitive per OrdinalIgnoreCase
+ var login = "C:0T.C|TENANT|aaaabbbb-cccc-dddd-eeee-ffffgggghhhh";
+ Assert.True(SharePointGroupResolver.IsAadGroup(login));
+ }
+
+ // ── ExtractAadGroupId ──────────────────────────────────────────────────────
+
+ [Fact]
+ public void ExtractAadGroupId_ValidAadLogin_ExtractsGuid()
+ {
+ var login = "c:0t.c|tenant|aaaabbbb-cccc-dddd-eeee-ffffgggghhhh";
+ var result = SharePointGroupResolver.ExtractAadGroupId(login);
+ Assert.Equal("aaaabbbb-cccc-dddd-eeee-ffffgggghhhh", result);
+ }
+
+ [Fact]
+ public void ExtractAadGroupId_SingleSegment_ReturnsFullString()
+ {
+ // Edge: no pipe — LastIndexOf returns -1 so [(-1+1)..] = [0..] = whole string
+ var login = "nopipe";
+ var result = SharePointGroupResolver.ExtractAadGroupId(login);
+ Assert.Equal("nopipe", result);
+ }
+
+ // ── StripClaims ────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void StripClaims_MembershipLogin_ReturnsUpn()
+ {
+ var login = "i:0#.f|membership|user@contoso.com";
+ var result = SharePointGroupResolver.StripClaims(login);
+ Assert.Equal("user@contoso.com", result);
+ }
+
+ [Fact]
+ public void StripClaims_NoClaimsPrefix_ReturnsFullString()
+ {
+ var login = "user@contoso.com";
+ var result = SharePointGroupResolver.StripClaims(login);
+ Assert.Equal("user@contoso.com", result);
+ }
+
+ [Fact]
+ public void StripClaims_MultiPipeLogin_ReturnsAfterLastPipe()
+ {
+ var login = "c:0t.c|tenant|some-guid-here";
+ var result = SharePointGroupResolver.StripClaims(login);
+ Assert.Equal("some-guid-here", result);
+ }
+
+ // ── ResolveGroupsAsync — empty input ──────────────────────────────────────
+
+ [Fact]
+ public async Task ResolveGroupsAsync_EmptyGroupNames_ReturnsEmptyDict()
+ {
+ // Arrange: create resolver without real dependencies — empty list triggers early return
+ // No CSOM ClientContext or GraphClientFactory is called for empty input
+ // We pass null! for the factory since it must not be invoked for an empty list
+ var resolver = new SharePointGroupResolver(null!);
+
+ // Act
+ var result = await resolver.ResolveGroupsAsync(
+ ctx: null!,
+ clientId: "ignored",
+ groupNames: Array.Empty(),
+ ct: CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public async Task ResolveGroupsAsync_EmptyGroupNames_DictUsesOrdinalIgnoreCase()
+ {
+ // Arrange
+ var resolver = new SharePointGroupResolver(null!);
+
+ // Act
+ var result = await resolver.ResolveGroupsAsync(
+ ctx: null!,
+ clientId: "ignored",
+ groupNames: Array.Empty(),
+ ct: CancellationToken.None);
+
+ // Assert: verify the returned dict is OrdinalIgnoreCase by inserting a value
+ // and looking it up with different casing — this validates the comparer used
+ // at construction time even on an empty dict
+ var mutable = new Dictionary(result.Comparer)
+ {
+ ["Site Members"] = 1
+ };
+ Assert.True(mutable.ContainsKey("site members"),
+ "Result dictionary comparer must be OrdinalIgnoreCase");
+ }
+
+ // ── Integration tests (live SP tenant required) ────────────────────────────
+
+ [Fact(Skip = "Requires live SP tenant — run manually against a real ClientContext")]
+ public async Task ResolveGroupsAsync_KnownGroup_ReturnsMembers()
+ {
+ // Integration test: create a real ClientContext, call with a known group name,
+ // verify the returned list contains at least one ResolvedMember.
+ await Task.CompletedTask;
+ }
+
+ [Fact(Skip = "Requires live SP tenant — verify case-insensitive lookup with real data")]
+ public async Task ResolveGroupsAsync_LookupDifferentCasing_FindsGroup()
+ {
+ // Integration test: resolver stores "Site Members" — lookup "site members" should succeed.
+ await Task.CompletedTask;
+ }
+}