diff --git a/SharepointToolbox.Tests/Services/Export/UserAccessCsvExportServiceTests.cs b/SharepointToolbox.Tests/Services/Export/UserAccessCsvExportServiceTests.cs index 4ee8e10..ee1479b 100644 --- a/SharepointToolbox.Tests/Services/Export/UserAccessCsvExportServiceTests.cs +++ b/SharepointToolbox.Tests/Services/Export/UserAccessCsvExportServiceTests.cs @@ -127,6 +127,122 @@ public class UserAccessCsvExportServiceTests } } + // ── RPT-03-f: mergePermissions=false produces identical output to default ── + + [Fact] + public async Task WriteSingleFileAsync_mergePermissionsfalse_produces_identical_output() + { + var alice1 = MakeEntry(userLogin: "alice@contoso.com", siteTitle: "Contoso", permLevel: "Read"); + var alice2 = MakeEntry(userLogin: "alice@contoso.com", siteTitle: "Dev Site", permLevel: "Read", + siteUrl: "https://contoso.sharepoint.com/sites/dev", objectUrl: "https://contoso.sharepoint.com/sites/dev/Docs"); + var bob = MakeEntry(userDisplay: "Bob Smith", userLogin: "bob@contoso.com", permLevel: "Contribute"); + + var entries = new[] { alice1, alice2, bob }; + var svc = new UserAccessCsvExportService(); + var tmpDefault = Path.GetTempFileName(); + var tmpExplicit = Path.GetTempFileName(); + try + { + // Default call (no mergePermissions param) + await svc.WriteSingleFileAsync(entries, tmpDefault, CancellationToken.None); + // Explicit mergePermissions=false + await svc.WriteSingleFileAsync(entries, tmpExplicit, CancellationToken.None, mergePermissions: false); + + var defaultContent = await File.ReadAllBytesAsync(tmpDefault); + var explicitContent = await File.ReadAllBytesAsync(tmpExplicit); + + Assert.Equal(defaultContent, explicitContent); + } + finally + { + File.Delete(tmpDefault); + File.Delete(tmpExplicit); + } + } + + // ── RPT-03-g: mergePermissions=true writes consolidated rows ────────────── + + [Fact] + public async Task WriteSingleFileAsync_mergePermissionstrue_writes_consolidated_rows() + { + // alice has 2 entries with same key (same login, permLevel, accessType, grantedThrough) + // they should be merged into 1 row with 2 locations + var alice1 = MakeEntry( + userDisplay: "Alice Smith", userLogin: "alice@contoso.com", + siteUrl: "https://contoso.sharepoint.com", siteTitle: "Contoso", + permLevel: "Read", accessType: AccessType.Direct, grantedThrough: "Direct Permissions"); + var alice2 = MakeEntry( + userDisplay: "Alice Smith", userLogin: "alice@contoso.com", + siteUrl: "https://dev.sharepoint.com", siteTitle: "Dev Site", + objectUrl: "https://dev.sharepoint.com/Docs", + permLevel: "Read", accessType: AccessType.Direct, grantedThrough: "Direct Permissions"); + // bob has a different key — separate row + var bob = MakeEntry( + userDisplay: "Bob Smith", userLogin: "bob@contoso.com", + siteTitle: "Contoso", permLevel: "Contribute", + accessType: AccessType.Direct, grantedThrough: "Direct Permissions"); + + var entries = new[] { alice1, alice2, bob }; + var svc = new UserAccessCsvExportService(); + var tmpFile = Path.GetTempFileName(); + try + { + await svc.WriteSingleFileAsync(entries, tmpFile, CancellationToken.None, mergePermissions: true); + var content = await File.ReadAllTextAsync(tmpFile); + + // Header must contain consolidated columns + Assert.Contains("\"User\",\"User Login\",\"Permission Level\",\"Access Type\",\"Granted Through\",\"Locations\",\"Location Count\"", content); + + // Alice's two entries merged — locations column contains both site titles + Assert.Contains("Contoso", content); + Assert.Contains("Dev Site", content); + + // Bob appears as a separate row + Assert.Contains("bob@contoso.com", content); + + // The consolidated report label should appear + Assert.Contains("User Access Audit Report (Consolidated)", content); + } + finally + { + File.Delete(tmpFile); + } + } + + // ── RPT-03-g edge case: single-location consolidated entry ──────────────── + + [Fact] + public async Task WriteSingleFileAsync_mergePermissionstrue_singleLocation_noSemicolon() + { + var entry = MakeEntry( + userDisplay: "Alice Smith", userLogin: "alice@contoso.com", + siteTitle: "Contoso", permLevel: "Read", + accessType: AccessType.Direct, grantedThrough: "Direct Permissions"); + + var svc = new UserAccessCsvExportService(); + var tmpFile = Path.GetTempFileName(); + try + { + await svc.WriteSingleFileAsync(new[] { entry }, tmpFile, CancellationToken.None, mergePermissions: true); + var content = await File.ReadAllTextAsync(tmpFile); + + // Should contain exactly "1" as LocationCount + Assert.Contains("\"1\"", content); + + // Locations field for a single entry should not contain a semicolon + // Find the data row for alice and verify no semicolon in Locations + var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries); + var dataRow = lines.FirstOrDefault(l => l.Contains("alice@contoso.com") && !l.StartsWith("\"Users")); + Assert.NotNull(dataRow); + // The Locations column value is "Contoso" with no semicolons + Assert.DoesNotContain("Contoso; ", dataRow); + } + finally + { + File.Delete(tmpFile); + } + } + // ── Private helpers ─────────────────────────────────────────────────────── ///