From 08e4d2ee7dfe46fc88618966772f9dc45c2de5ff Mon Sep 17 00:00:00 2001 From: Dev Date: Thu, 2 Apr 2026 15:25:20 +0200 Subject: [PATCH] feat(03-01): create Phase 3 export stubs and test scaffolds - Add StorageCsvExportService, StorageHtmlExportService stub (Plan 03-03) - Add SearchCsvExportService, SearchHtmlExportService stub (Plan 03-05) - Add DuplicatesHtmlExportService stub (Plan 03-05) - Add StorageServiceTests, SearchServiceTests, DuplicatesServiceTests scaffolds - Add export test scaffolds for all 4 Phase 3 export services - 7 pure-logic tests pass (VersionSizeBytes + MakeKey); 4 CSOM stubs skip --- .../Services/DuplicatesServiceTests.cs | 80 ++++++++++++++++++ .../DuplicatesHtmlExportServiceTests.cs | 53 ++++++++++++ .../Export/SearchExportServiceTests.cs | 82 +++++++++++++++++++ .../Export/StorageCsvExportServiceTests.cs | 52 ++++++++++++ .../Export/StorageHtmlExportServiceTests.cs | 51 ++++++++++++ .../Services/SearchServiceTests.cs | 20 +++++ .../Services/StorageServiceTests.cs | 31 +++++++ .../Export/DuplicatesHtmlExportService.cs | 14 ++++ .../Services/Export/SearchCsvExportService.cs | 14 ++++ .../Export/SearchHtmlExportService.cs | 14 ++++ .../Export/StorageCsvExportService.cs | 14 ++++ .../Export/StorageHtmlExportService.cs | 14 ++++ 12 files changed, 439 insertions(+) create mode 100644 SharepointToolbox.Tests/Services/DuplicatesServiceTests.cs create mode 100644 SharepointToolbox.Tests/Services/Export/DuplicatesHtmlExportServiceTests.cs create mode 100644 SharepointToolbox.Tests/Services/Export/SearchExportServiceTests.cs create mode 100644 SharepointToolbox.Tests/Services/Export/StorageCsvExportServiceTests.cs create mode 100644 SharepointToolbox.Tests/Services/Export/StorageHtmlExportServiceTests.cs create mode 100644 SharepointToolbox.Tests/Services/SearchServiceTests.cs create mode 100644 SharepointToolbox.Tests/Services/StorageServiceTests.cs create mode 100644 SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs create mode 100644 SharepointToolbox/Services/Export/SearchCsvExportService.cs create mode 100644 SharepointToolbox/Services/Export/SearchHtmlExportService.cs create mode 100644 SharepointToolbox/Services/Export/StorageCsvExportService.cs create mode 100644 SharepointToolbox/Services/Export/StorageHtmlExportService.cs diff --git a/SharepointToolbox.Tests/Services/DuplicatesServiceTests.cs b/SharepointToolbox.Tests/Services/DuplicatesServiceTests.cs new file mode 100644 index 0000000..4798d23 --- /dev/null +++ b/SharepointToolbox.Tests/Services/DuplicatesServiceTests.cs @@ -0,0 +1,80 @@ +using SharepointToolbox.Core.Models; +using SharepointToolbox.Services; +using Xunit; + +namespace SharepointToolbox.Tests.Services; + +/// +/// Pure-logic tests for the MakeKey composite key function (no CSOM needed). +/// Inline helper matches the implementation DuplicatesService will produce in Plan 03-04. +/// +public class DuplicatesServiceTests +{ + // Inline copy of MakeKey to test logic before Plan 03-04 creates the real class + private static string MakeKey(DuplicateItem item, DuplicateScanOptions opts) + { + var parts = new System.Collections.Generic.List { item.Name.ToLowerInvariant() }; + if (opts.MatchSize && item.SizeBytes.HasValue) parts.Add(item.SizeBytes.Value.ToString()); + if (opts.MatchCreated && item.Created.HasValue) parts.Add(item.Created.Value.Date.ToString("yyyy-MM-dd")); + if (opts.MatchModified && item.Modified.HasValue) parts.Add(item.Modified.Value.Date.ToString("yyyy-MM-dd")); + if (opts.MatchSubfolderCount && item.FolderCount.HasValue) parts.Add(item.FolderCount.Value.ToString()); + if (opts.MatchFileCount && item.FileCount.HasValue) parts.Add(item.FileCount.Value.ToString()); + return string.Join("|", parts); + } + + [Fact] + public void MakeKey_NameOnly_ReturnsLowercaseName() + { + var item = new DuplicateItem { Name = "Report.docx", SizeBytes = 1000 }; + var opts = new DuplicateScanOptions(MatchSize: false); + Assert.Equal("report.docx", MakeKey(item, opts)); + } + + [Fact] + public void MakeKey_WithSizeMatch_AppendsSizeToKey() + { + var item = new DuplicateItem { Name = "Report.docx", SizeBytes = 1024 }; + var opts = new DuplicateScanOptions(MatchSize: true); + Assert.Equal("report.docx|1024", MakeKey(item, opts)); + } + + [Fact] + public void MakeKey_WithCreatedAndModified_AppendsDateStrings() + { + var item = new DuplicateItem + { + Name = "file.pdf", + SizeBytes = 500, + Created = new DateTime(2024, 3, 15), + Modified = new DateTime(2024, 6, 1) + }; + var opts = new DuplicateScanOptions(MatchSize: false, MatchCreated: true, MatchModified: true); + Assert.Equal("file.pdf|2024-03-15|2024-06-01", MakeKey(item, opts)); + } + + [Fact] + public void MakeKey_SameKeyForSameItems_GroupsCorrectly() + { + var opts = new DuplicateScanOptions(MatchSize: true); + var item1 = new DuplicateItem { Name = "Budget.xlsx", SizeBytes = 2048 }; + var item2 = new DuplicateItem { Name = "BUDGET.xlsx", SizeBytes = 2048 }; + Assert.Equal(MakeKey(item1, opts), MakeKey(item2, opts)); + } + + [Fact] + public void MakeKey_DifferentSize_ProducesDifferentKeys() + { + var opts = new DuplicateScanOptions(MatchSize: true); + var item1 = new DuplicateItem { Name = "file.docx", SizeBytes = 100 }; + var item2 = new DuplicateItem { Name = "file.docx", SizeBytes = 200 }; + Assert.NotEqual(MakeKey(item1, opts), MakeKey(item2, opts)); + } + + [Fact(Skip = "Requires live CSOM context — covered by Plan 03-04 implementation")] + public Task ScanDuplicatesAsync_Files_GroupsByCompositeKey() + => Task.CompletedTask; + + [Fact(Skip = "Requires live CSOM context — covered by Plan 03-04 implementation")] + public Task ScanDuplicatesAsync_Folders_UsesCamlFSObjType1() + => Task.CompletedTask; +} diff --git a/SharepointToolbox.Tests/Services/Export/DuplicatesHtmlExportServiceTests.cs b/SharepointToolbox.Tests/Services/Export/DuplicatesHtmlExportServiceTests.cs new file mode 100644 index 0000000..c1af935 --- /dev/null +++ b/SharepointToolbox.Tests/Services/Export/DuplicatesHtmlExportServiceTests.cs @@ -0,0 +1,53 @@ +using SharepointToolbox.Core.Models; +using SharepointToolbox.Services.Export; +using Xunit; + +namespace SharepointToolbox.Tests.Services.Export; + +public class DuplicatesHtmlExportServiceTests +{ + private static DuplicateGroup MakeGroup(string name, int count) => new() + { + GroupKey = $"{name}|1024", + Name = name, + Items = Enumerable.Range(1, count).Select(i => new DuplicateItem + { + Name = name, + Path = $"https://contoso.sharepoint.com/sites/Site{i}/{name}", + Library = "Shared Documents", + SizeBytes = 1024 + }).ToList() + }; + + [Fact] + public void BuildHtml_WithGroups_ContainsGroupCards() + { + var svc = new DuplicatesHtmlExportService(); + var groups = new List { MakeGroup("report.docx", 3) }; + var html = svc.BuildHtml(groups); + Assert.Contains("", html); + Assert.Contains("report.docx", html); + } + + [Fact] + public void BuildHtml_WithMultipleGroups_AllGroupNamesPresent() + { + var svc = new DuplicatesHtmlExportService(); + var groups = new List + { + MakeGroup("budget.xlsx", 2), + MakeGroup("photo.jpg", 4) + }; + var html = svc.BuildHtml(groups); + Assert.Contains("budget.xlsx", html); + Assert.Contains("photo.jpg", html); + } + + [Fact] + public void BuildHtml_WithEmptyList_ReturnsValidHtml() + { + var svc = new DuplicatesHtmlExportService(); + var html = svc.BuildHtml(new List()); + Assert.Contains("", html); + } +} diff --git a/SharepointToolbox.Tests/Services/Export/SearchExportServiceTests.cs b/SharepointToolbox.Tests/Services/Export/SearchExportServiceTests.cs new file mode 100644 index 0000000..bf63a09 --- /dev/null +++ b/SharepointToolbox.Tests/Services/Export/SearchExportServiceTests.cs @@ -0,0 +1,82 @@ +using SharepointToolbox.Core.Models; +using SharepointToolbox.Services.Export; +using Xunit; + +namespace SharepointToolbox.Tests.Services.Export; + +public class SearchExportServiceTests +{ + private static SearchResult MakeSample() => new() + { + Title = "Q1 Budget.xlsx", + Path = "https://contoso.sharepoint.com/sites/Finance/Shared Documents/Q1 Budget.xlsx", + FileExtension = "xlsx", + Created = new DateTime(2024, 1, 10), + LastModified = new DateTime(2024, 3, 20), + Author = "Alice Smith", + ModifiedBy = "Bob Jones", + SizeBytes = 48_000 + }; + + // -- CSV tests ----------------------------------------------------------- + + [Fact] + public void BuildCsv_WithKnownResults_ContainsExpectedHeader() + { + var svc = new SearchCsvExportService(); + var csv = svc.BuildCsv(new List { MakeSample() }); + Assert.Contains("File Name", csv); + Assert.Contains("Extension", csv); + Assert.Contains("Created", csv); + Assert.Contains("Created By", csv); + Assert.Contains("Modified By", csv); + Assert.Contains("Size", csv); + } + + [Fact] + public void BuildCsv_WithEmptyList_ReturnsHeaderOnly() + { + var svc = new SearchCsvExportService(); + var csv = svc.BuildCsv(new List()); + Assert.NotEmpty(csv); + var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Single(lines); + } + + [Fact] + public void BuildCsv_ResultValues_AppearInOutput() + { + var svc = new SearchCsvExportService(); + var csv = svc.BuildCsv(new List { MakeSample() }); + Assert.Contains("Alice Smith", csv); + Assert.Contains("xlsx", csv); + } + + // -- HTML tests ---------------------------------------------------------- + + [Fact] + public void BuildHtml_WithResults_ContainsSortableColumnScript() + { + var svc = new SearchHtmlExportService(); + var html = svc.BuildHtml(new List { MakeSample() }); + Assert.Contains("", html); + Assert.Contains("sort", html); // sortable columns JS + Assert.Contains("Q1 Budget.xlsx", html); + } + + [Fact] + public void BuildHtml_WithResults_ContainsFilterInput() + { + var svc = new SearchHtmlExportService(); + var html = svc.BuildHtml(new List { MakeSample() }); + Assert.Contains("filter", html); // filter input element + } + + [Fact] + public void BuildHtml_WithEmptyList_ReturnsValidHtml() + { + var svc = new SearchHtmlExportService(); + var html = svc.BuildHtml(new List()); + Assert.Contains("", html); + } +} diff --git a/SharepointToolbox.Tests/Services/Export/StorageCsvExportServiceTests.cs b/SharepointToolbox.Tests/Services/Export/StorageCsvExportServiceTests.cs new file mode 100644 index 0000000..8caef66 --- /dev/null +++ b/SharepointToolbox.Tests/Services/Export/StorageCsvExportServiceTests.cs @@ -0,0 +1,52 @@ +using SharepointToolbox.Core.Models; +using SharepointToolbox.Services.Export; +using Xunit; + +namespace SharepointToolbox.Tests.Services.Export; + +public class StorageCsvExportServiceTests +{ + [Fact] + public void BuildCsv_WithKnownNodes_ProducesHeaderRow() + { + var svc = new StorageCsvExportService(); + var nodes = new List + { + new() { Name = "Shared Documents", Library = "Shared Documents", SiteTitle = "MySite", + TotalSizeBytes = 1024, FileStreamSizeBytes = 800, TotalFileCount = 5, + LastModified = new DateTime(2024, 1, 15) } + }; + var csv = svc.BuildCsv(nodes); + Assert.Contains("Library", csv); + Assert.Contains("Site", csv); + Assert.Contains("Files", csv); + Assert.Contains("Total Size", csv); + Assert.Contains("Version Size", csv); + Assert.Contains("Last Modified", csv); + } + + [Fact] + public void BuildCsv_WithEmptyList_ReturnsHeaderOnly() + { + var svc = new StorageCsvExportService(); + var csv = svc.BuildCsv(new List()); + Assert.NotEmpty(csv); // must have at least the header row + var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Single(lines); // only header, no data rows + } + + [Fact] + public void BuildCsv_NodeValues_AppearInOutput() + { + var svc = new StorageCsvExportService(); + var nodes = new List + { + new() { Name = "Reports", Library = "Reports", SiteTitle = "ProjectSite", + TotalSizeBytes = 2048, FileStreamSizeBytes = 1024, TotalFileCount = 10 } + }; + var csv = svc.BuildCsv(nodes); + Assert.Contains("Reports", csv); + Assert.Contains("ProjectSite", csv); + Assert.Contains("10", csv); + } +} diff --git a/SharepointToolbox.Tests/Services/Export/StorageHtmlExportServiceTests.cs b/SharepointToolbox.Tests/Services/Export/StorageHtmlExportServiceTests.cs new file mode 100644 index 0000000..ec98c9b --- /dev/null +++ b/SharepointToolbox.Tests/Services/Export/StorageHtmlExportServiceTests.cs @@ -0,0 +1,51 @@ +using SharepointToolbox.Core.Models; +using SharepointToolbox.Services.Export; +using Xunit; + +namespace SharepointToolbox.Tests.Services.Export; + +public class StorageHtmlExportServiceTests +{ + [Fact] + public void BuildHtml_WithNodes_ContainsToggleJs() + { + var svc = new StorageHtmlExportService(); + var nodes = new List + { + new() { Name = "Shared Documents", Library = "Shared Documents", SiteTitle = "Site1", + TotalSizeBytes = 5000, FileStreamSizeBytes = 4000, TotalFileCount = 20, + Children = new List + { + new() { Name = "Archive", Library = "Shared Documents", SiteTitle = "Site1", + TotalSizeBytes = 1000, FileStreamSizeBytes = 800, TotalFileCount = 5 } + } } + }; + var html = svc.BuildHtml(nodes); + Assert.Contains("toggle(", html); + Assert.Contains("", html); + Assert.Contains("Shared Documents", html); + } + + [Fact] + public void BuildHtml_WithEmptyList_ReturnsValidHtml() + { + var svc = new StorageHtmlExportService(); + var html = svc.BuildHtml(new List()); + Assert.Contains("", html); + Assert.Contains(" + { + new() { Name = "Documents", Library = "Documents", SiteTitle = "Site1", TotalSizeBytes = 1000 }, + new() { Name = "Images", Library = "Images", SiteTitle = "Site1", TotalSizeBytes = 2000 } + }; + var html = svc.BuildHtml(nodes); + Assert.Contains("Documents", html); + Assert.Contains("Images", html); + } +} diff --git a/SharepointToolbox.Tests/Services/SearchServiceTests.cs b/SharepointToolbox.Tests/Services/SearchServiceTests.cs new file mode 100644 index 0000000..b771ad2 --- /dev/null +++ b/SharepointToolbox.Tests/Services/SearchServiceTests.cs @@ -0,0 +1,20 @@ +using SharepointToolbox.Core.Models; +using SharepointToolbox.Services; +using Xunit; + +namespace SharepointToolbox.Tests.Services; + +public class SearchServiceTests +{ + [Fact(Skip = "Requires live CSOM context — covered by Plan 03-04 implementation")] + public Task SearchFilesAsync_WithExtensionFilter_BuildsCorrectKql() + => Task.CompletedTask; + + [Fact(Skip = "Requires live CSOM context — covered by Plan 03-04 implementation")] + public Task SearchFilesAsync_PaginationStopsAt50000() + => Task.CompletedTask; + + [Fact(Skip = "Requires live CSOM context — covered by Plan 03-04 implementation")] + public Task SearchFilesAsync_FiltersVersionHistoryPaths() + => Task.CompletedTask; +} diff --git a/SharepointToolbox.Tests/Services/StorageServiceTests.cs b/SharepointToolbox.Tests/Services/StorageServiceTests.cs new file mode 100644 index 0000000..ed8e3a7 --- /dev/null +++ b/SharepointToolbox.Tests/Services/StorageServiceTests.cs @@ -0,0 +1,31 @@ +using SharepointToolbox.Core.Models; +using SharepointToolbox.Services; +using Xunit; + +namespace SharepointToolbox.Tests.Services; + +public class StorageServiceTests +{ + [Fact(Skip = "Requires live CSOM context — covered by Plan 03-02 implementation")] + public Task CollectStorageAsync_ReturnsLibraryNodes_ForDocumentLibraries() + => Task.CompletedTask; + + [Fact(Skip = "Requires live CSOM context — covered by Plan 03-02 implementation")] + public Task CollectStorageAsync_WithFolderDepth1_ReturnsSubfolderNodes() + => Task.CompletedTask; + + [Fact] + public void StorageNode_VersionSizeBytes_IsNonNegative() + { + // VersionSizeBytes = TotalSizeBytes - FileStreamSizeBytes (never negative) + var node = new StorageNode { TotalSizeBytes = 1000L, FileStreamSizeBytes = 1200L }; + Assert.Equal(0L, node.VersionSizeBytes); // Math.Max(0, -200) = 0 + } + + [Fact] + public void StorageNode_VersionSizeBytes_IsCorrectWhenPositive() + { + var node = new StorageNode { TotalSizeBytes = 5000L, FileStreamSizeBytes = 3000L }; + Assert.Equal(2000L, node.VersionSizeBytes); + } +} diff --git a/SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs b/SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs new file mode 100644 index 0000000..3376a86 --- /dev/null +++ b/SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs @@ -0,0 +1,14 @@ +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Services.Export; + +public class DuplicatesHtmlExportService +{ + public string BuildHtml(IReadOnlyList groups) => string.Empty; // implemented in Plan 03-05 + + public async Task WriteAsync(IReadOnlyList groups, string filePath, CancellationToken ct) + { + var html = BuildHtml(groups); + await System.IO.File.WriteAllTextAsync(filePath, html, System.Text.Encoding.UTF8, ct); + } +} diff --git a/SharepointToolbox/Services/Export/SearchCsvExportService.cs b/SharepointToolbox/Services/Export/SearchCsvExportService.cs new file mode 100644 index 0000000..95fb036 --- /dev/null +++ b/SharepointToolbox/Services/Export/SearchCsvExportService.cs @@ -0,0 +1,14 @@ +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Services.Export; + +public class SearchCsvExportService +{ + public string BuildCsv(IReadOnlyList results) => string.Empty; // implemented in Plan 03-05 + + public async Task WriteAsync(IReadOnlyList results, string filePath, CancellationToken ct) + { + var csv = BuildCsv(results); + await System.IO.File.WriteAllTextAsync(filePath, csv, new System.Text.UTF8Encoding(true), ct); + } +} diff --git a/SharepointToolbox/Services/Export/SearchHtmlExportService.cs b/SharepointToolbox/Services/Export/SearchHtmlExportService.cs new file mode 100644 index 0000000..67df080 --- /dev/null +++ b/SharepointToolbox/Services/Export/SearchHtmlExportService.cs @@ -0,0 +1,14 @@ +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Services.Export; + +public class SearchHtmlExportService +{ + public string BuildHtml(IReadOnlyList results) => string.Empty; // implemented in Plan 03-05 + + public async Task WriteAsync(IReadOnlyList results, string filePath, CancellationToken ct) + { + var html = BuildHtml(results); + await System.IO.File.WriteAllTextAsync(filePath, html, System.Text.Encoding.UTF8, ct); + } +} diff --git a/SharepointToolbox/Services/Export/StorageCsvExportService.cs b/SharepointToolbox/Services/Export/StorageCsvExportService.cs new file mode 100644 index 0000000..a42b619 --- /dev/null +++ b/SharepointToolbox/Services/Export/StorageCsvExportService.cs @@ -0,0 +1,14 @@ +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Services.Export; + +public class StorageCsvExportService +{ + public string BuildCsv(IReadOnlyList nodes) => string.Empty; // implemented in Plan 03-03 + + public async Task WriteAsync(IReadOnlyList nodes, string filePath, CancellationToken ct) + { + var csv = BuildCsv(nodes); + await System.IO.File.WriteAllTextAsync(filePath, csv, new System.Text.UTF8Encoding(true), ct); + } +} diff --git a/SharepointToolbox/Services/Export/StorageHtmlExportService.cs b/SharepointToolbox/Services/Export/StorageHtmlExportService.cs new file mode 100644 index 0000000..3818d42 --- /dev/null +++ b/SharepointToolbox/Services/Export/StorageHtmlExportService.cs @@ -0,0 +1,14 @@ +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Services.Export; + +public class StorageHtmlExportService +{ + public string BuildHtml(IReadOnlyList nodes) => string.Empty; // implemented in Plan 03-03 + + public async Task WriteAsync(IReadOnlyList nodes, string filePath, CancellationToken ct) + { + var html = BuildHtml(nodes); + await System.IO.File.WriteAllTextAsync(filePath, html, System.Text.Encoding.UTF8, ct); + } +}