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 casting to Dictionary // and checking its comparer, or by testing that the underlying type supports it. // Since ResolveGroupsAsync returns a Dictionary wrapped as IReadOnlyDictionary, // we cast back and insert a test entry with mixed casing. var mutable = (Dictionary>)result; mutable["Site Members"] = Array.Empty(); 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; } }