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);
+ }
+}