Files
Sharepoint-Toolbox/.planning/milestones/v1.0-phases/03-storage/03-01-PLAN.md
Dev 655bb79a99
All checks were successful
Release zip package / release (push) Successful in 10s
chore: complete v1.0 milestone
Archive 5 phases (36 plans) to milestones/v1.0-phases/.
Archive roadmap, requirements, and audit to milestones/.
Evolve PROJECT.md with shipped state and validated requirements.
Collapse ROADMAP.md to one-line milestone summary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:15:14 +02:00

29 KiB

phase, plan, title, status, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan title status wave depends_on files_modified autonomous requirements must_haves
03 01 Wave 0 — Test Scaffolds, Stub Interfaces, and Core Models pending 0
SharepointToolbox/Core/Models/StorageNode.cs
SharepointToolbox/Core/Models/StorageScanOptions.cs
SharepointToolbox/Core/Models/SearchResult.cs
SharepointToolbox/Core/Models/SearchOptions.cs
SharepointToolbox/Core/Models/DuplicateGroup.cs
SharepointToolbox/Core/Models/DuplicateItem.cs
SharepointToolbox/Core/Models/DuplicateScanOptions.cs
SharepointToolbox/Services/IStorageService.cs
SharepointToolbox/Services/ISearchService.cs
SharepointToolbox/Services/IDuplicatesService.cs
SharepointToolbox/Services/Export/StorageCsvExportService.cs
SharepointToolbox/Services/Export/StorageHtmlExportService.cs
SharepointToolbox/Services/Export/SearchCsvExportService.cs
SharepointToolbox/Services/Export/SearchHtmlExportService.cs
SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs
SharepointToolbox.Tests/Services/StorageServiceTests.cs
SharepointToolbox.Tests/Services/SearchServiceTests.cs
SharepointToolbox.Tests/Services/DuplicatesServiceTests.cs
SharepointToolbox.Tests/Services/Export/StorageCsvExportServiceTests.cs
SharepointToolbox.Tests/Services/Export/StorageHtmlExportServiceTests.cs
SharepointToolbox.Tests/Services/Export/SearchExportServiceTests.cs
SharepointToolbox.Tests/Services/Export/DuplicatesHtmlExportServiceTests.cs
true
STOR-01
STOR-02
STOR-03
STOR-04
STOR-05
SRCH-01
SRCH-02
SRCH-03
SRCH-04
DUPL-01
DUPL-02
DUPL-03
truths artifacts key_links
dotnet build produces 0 errors after all 7 models, 3 interfaces, and 5 stub export classes are created
All 7 test files exist and are discovered by dotnet test (test count > 0)
StorageServiceTests, SearchServiceTests, DuplicatesServiceTests compile but skip (stubs referencing types that exist after this plan)
The pure-logic tests in DuplicatesServiceTests (MakeKey composite key) are real [Fact] tests — not skipped — and pass
Export service tests compile but fail (types exist as stubs with no real implementation yet) — expected until Plans 03/05
path provides
SharepointToolbox/Core/Models/StorageNode.cs Tree node model for storage metrics display
path provides
SharepointToolbox/Core/Models/SearchResult.cs Flat result record for file search output
path provides
SharepointToolbox/Core/Models/DuplicateGroup.cs Group record for duplicate detection output
path provides
SharepointToolbox/Services/IStorageService.cs Interface enabling ViewModel mocking for storage
path provides
SharepointToolbox/Services/ISearchService.cs Interface enabling ViewModel mocking for search
path provides
SharepointToolbox/Services/IDuplicatesService.cs Interface enabling ViewModel mocking for duplicates
from to via pattern
StorageServiceTests.cs IStorageService mock interface IStorageService
from to via pattern
SearchServiceTests.cs ISearchService mock interface ISearchService
from to via pattern
DuplicatesServiceTests.cs MakeKey static pure function MakeKey

Plan 03-01: Wave 0 — Test Scaffolds, Stub Interfaces, and Core Models

Goal

Create all data models, service interfaces, export service stubs, and test scaffolds needed so every subsequent plan has a working dotnet test --filter verify command pointing at a real test class. Interfaces and models define the contracts; implementation plans (03-02 through 03-05) fill them in. One set of pure-logic tests (the MakeKey composite key function for duplicate detection) are real [Fact] tests that pass immediately since the logic is pure and has no CSOM dependencies.

Context

Phase 2 created PermissionEntry, ScanOptions, IPermissionsService, and test scaffolds in exactly this pattern. Phase 3 follows the same Wave 0 approach: models + interfaces first, implementation in subsequent plans. The test project at SharepointToolbox.Tests/SharepointToolbox.Tests.csproj already has xUnit 2.9.3 + Moq. The export service stubs must compile (the test files reference them) even though their BuildCsv/BuildHtml methods return empty strings until implemented.

Tasks

Task 1: Create all 7 core models and 3 service interfaces

Files:

  • SharepointToolbox/Core/Models/StorageNode.cs
  • SharepointToolbox/Core/Models/StorageScanOptions.cs
  • SharepointToolbox/Core/Models/SearchResult.cs
  • SharepointToolbox/Core/Models/SearchOptions.cs
  • SharepointToolbox/Core/Models/DuplicateGroup.cs
  • SharepointToolbox/Core/Models/DuplicateItem.cs
  • SharepointToolbox/Core/Models/DuplicateScanOptions.cs
  • SharepointToolbox/Services/IStorageService.cs
  • SharepointToolbox/Services/ISearchService.cs
  • SharepointToolbox/Services/IDuplicatesService.cs

Action: Create | Write

Why: All subsequent plans depend on these contracts. Tests must compile against them. Interfaces enable Moq-based unit tests.

// SharepointToolbox/Core/Models/StorageNode.cs
namespace SharepointToolbox.Core.Models;

public class StorageNode
{
    public string Name { get; set; } = string.Empty;
    public string Url { get; set; } = string.Empty;
    public string SiteTitle { get; set; } = string.Empty;
    public string Library { get; set; } = string.Empty;
    public long TotalSizeBytes { get; set; }
    public long FileStreamSizeBytes { get; set; }
    public long VersionSizeBytes => Math.Max(0L, TotalSizeBytes - FileStreamSizeBytes);
    public long TotalFileCount { get; set; }
    public DateTime? LastModified { get; set; }
    public int IndentLevel { get; set; }
    public List<StorageNode> Children { get; set; } = new();
}
// SharepointToolbox/Core/Models/StorageScanOptions.cs
namespace SharepointToolbox.Core.Models;

public record StorageScanOptions(
    bool PerLibrary = true,
    bool IncludeSubsites = false,
    int FolderDepth = 0   // 0 = library root only; >0 = recurse N levels
);
// SharepointToolbox/Core/Models/SearchResult.cs
namespace SharepointToolbox.Core.Models;

public class SearchResult
{
    public string Title { get; set; } = string.Empty;
    public string Path { get; set; } = string.Empty;
    public string FileExtension { get; set; } = string.Empty;
    public DateTime? Created { get; set; }
    public DateTime? LastModified { get; set; }
    public string Author { get; set; } = string.Empty;
    public string ModifiedBy { get; set; } = string.Empty;
    public long SizeBytes { get; set; }
}
// SharepointToolbox/Core/Models/SearchOptions.cs
namespace SharepointToolbox.Core.Models;

public record SearchOptions(
    string[] Extensions,
    string? Regex,
    DateTime? CreatedAfter,
    DateTime? CreatedBefore,
    DateTime? ModifiedAfter,
    DateTime? ModifiedBefore,
    string? CreatedBy,
    string? ModifiedBy,
    string? Library,
    int MaxResults,
    string SiteUrl
);
// SharepointToolbox/Core/Models/DuplicateItem.cs
namespace SharepointToolbox.Core.Models;

public class DuplicateItem
{
    public string Name { get; set; } = string.Empty;
    public string Path { get; set; } = string.Empty;
    public string Library { get; set; } = string.Empty;
    public long? SizeBytes { get; set; }
    public DateTime? Created { get; set; }
    public DateTime? Modified { get; set; }
    public int? FolderCount { get; set; }
    public int? FileCount { get; set; }
}
// SharepointToolbox/Core/Models/DuplicateGroup.cs
namespace SharepointToolbox.Core.Models;

public class DuplicateGroup
{
    public string GroupKey { get; set; } = string.Empty;
    public string Name { get; set; } = string.Empty;
    public List<DuplicateItem> Items { get; set; } = new();
}
// SharepointToolbox/Core/Models/DuplicateScanOptions.cs
namespace SharepointToolbox.Core.Models;

public record DuplicateScanOptions(
    string Mode = "Files",          // "Files" or "Folders"
    bool MatchSize = true,
    bool MatchCreated = false,
    bool MatchModified = false,
    bool MatchSubfolderCount = false,
    bool MatchFileCount = false,
    bool IncludeSubsites = false,
    string? Library = null
);
// SharepointToolbox/Services/IStorageService.cs
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;

namespace SharepointToolbox.Services;

public interface IStorageService
{
    Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
        ClientContext ctx,
        StorageScanOptions options,
        IProgress<OperationProgress> progress,
        CancellationToken ct);
}
// SharepointToolbox/Services/ISearchService.cs
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;

namespace SharepointToolbox.Services;

public interface ISearchService
{
    Task<IReadOnlyList<SearchResult>> SearchFilesAsync(
        ClientContext ctx,
        SearchOptions options,
        IProgress<OperationProgress> progress,
        CancellationToken ct);
}
// SharepointToolbox/Services/IDuplicatesService.cs
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;

namespace SharepointToolbox.Services;

public interface IDuplicatesService
{
    Task<IReadOnlyList<DuplicateGroup>> ScanDuplicatesAsync(
        ClientContext ctx,
        DuplicateScanOptions options,
        IProgress<OperationProgress> progress,
        CancellationToken ct);
}

Verification:

dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx

Expected: 0 errors

Task 2: Create 5 export service stubs and 7 test scaffold files

Files:

  • SharepointToolbox/Services/Export/StorageCsvExportService.cs
  • SharepointToolbox/Services/Export/StorageHtmlExportService.cs
  • SharepointToolbox/Services/Export/SearchCsvExportService.cs
  • SharepointToolbox/Services/Export/SearchHtmlExportService.cs
  • SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs
  • SharepointToolbox.Tests/Services/StorageServiceTests.cs
  • SharepointToolbox.Tests/Services/SearchServiceTests.cs
  • SharepointToolbox.Tests/Services/DuplicatesServiceTests.cs
  • SharepointToolbox.Tests/Services/Export/StorageCsvExportServiceTests.cs
  • SharepointToolbox.Tests/Services/Export/StorageHtmlExportServiceTests.cs
  • SharepointToolbox.Tests/Services/Export/SearchExportServiceTests.cs
  • SharepointToolbox.Tests/Services/Export/DuplicatesHtmlExportServiceTests.cs

Action: Create | Write

Why: Stubs enable test files to compile. The MakeKey helper and VersionSizeBytes derived property can be unit tested immediately without any CSOM. Export service tests will fail until plans 03-03 and 03-05 implement the real logic — that is the expected state.

// SharepointToolbox/Services/Export/StorageCsvExportService.cs
using SharepointToolbox.Core.Models;

namespace SharepointToolbox.Services.Export;

public class StorageCsvExportService
{
    public string BuildCsv(IReadOnlyList<StorageNode> nodes) => string.Empty; // implemented in Plan 03-03

    public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct)
    {
        var csv = BuildCsv(nodes);
        await System.IO.File.WriteAllTextAsync(filePath, csv, new System.Text.UTF8Encoding(true), ct);
    }
}
// SharepointToolbox/Services/Export/StorageHtmlExportService.cs
using SharepointToolbox.Core.Models;

namespace SharepointToolbox.Services.Export;

public class StorageHtmlExportService
{
    public string BuildHtml(IReadOnlyList<StorageNode> nodes) => string.Empty; // implemented in Plan 03-03

    public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct)
    {
        var html = BuildHtml(nodes);
        await System.IO.File.WriteAllTextAsync(filePath, html, System.Text.Encoding.UTF8, ct);
    }
}
// SharepointToolbox/Services/Export/SearchCsvExportService.cs
using SharepointToolbox.Core.Models;

namespace SharepointToolbox.Services.Export;

public class SearchCsvExportService
{
    public string BuildCsv(IReadOnlyList<SearchResult> results) => string.Empty; // implemented in Plan 03-05

    public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct)
    {
        var csv = BuildCsv(results);
        await System.IO.File.WriteAllTextAsync(filePath, csv, new System.Text.UTF8Encoding(true), ct);
    }
}
// SharepointToolbox/Services/Export/SearchHtmlExportService.cs
using SharepointToolbox.Core.Models;

namespace SharepointToolbox.Services.Export;

public class SearchHtmlExportService
{
    public string BuildHtml(IReadOnlyList<SearchResult> results) => string.Empty; // implemented in Plan 03-05

    public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct)
    {
        var html = BuildHtml(results);
        await System.IO.File.WriteAllTextAsync(filePath, html, System.Text.Encoding.UTF8, ct);
    }
}
// SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs
using SharepointToolbox.Core.Models;

namespace SharepointToolbox.Services.Export;

public class DuplicatesHtmlExportService
{
    public string BuildHtml(IReadOnlyList<DuplicateGroup> groups) => string.Empty; // implemented in Plan 03-05

    public async Task WriteAsync(IReadOnlyList<DuplicateGroup> groups, string filePath, CancellationToken ct)
    {
        var html = BuildHtml(groups);
        await System.IO.File.WriteAllTextAsync(filePath, html, System.Text.Encoding.UTF8, ct);
    }
}

Now the test scaffold files. The DuplicatesServiceTests includes a real pure-logic test for MakeKey — define the helper class inline in the same file so it compiles without depending on DuplicatesService:

// SharepointToolbox.Tests/Services/StorageServiceTests.cs
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);
    }
}
// SharepointToolbox.Tests/Services/SearchServiceTests.cs
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;
}
// SharepointToolbox.Tests/Services/DuplicatesServiceTests.cs
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using Xunit;

namespace SharepointToolbox.Tests.Services;

/// <summary>
/// Pure-logic tests for the MakeKey composite key function (no CSOM needed).
/// Inline helper matches the implementation DuplicatesService will produce in Plan 03-04.
/// </summary>
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<string> { 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;
}
// SharepointToolbox.Tests/Services/Export/StorageCsvExportServiceTests.cs
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<StorageNode>
        {
            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<StorageNode>());
        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<StorageNode>
        {
            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);
    }
}
// SharepointToolbox.Tests/Services/Export/StorageHtmlExportServiceTests.cs
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<StorageNode>
        {
            new() { Name = "Shared Documents", Library = "Shared Documents", SiteTitle = "Site1",
                    TotalSizeBytes = 5000, FileStreamSizeBytes = 4000, TotalFileCount = 20,
                    Children = new List<StorageNode>
                    {
                        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("<!DOCTYPE html>", html);
        Assert.Contains("Shared Documents", html);
    }

    [Fact]
    public void BuildHtml_WithEmptyList_ReturnsValidHtml()
    {
        var svc = new StorageHtmlExportService();
        var html = svc.BuildHtml(new List<StorageNode>());
        Assert.Contains("<!DOCTYPE html>", html);
        Assert.Contains("<html", html);
    }

    [Fact]
    public void BuildHtml_WithMultipleLibraries_EachLibraryAppearsInOutput()
    {
        var svc = new StorageHtmlExportService();
        var nodes = new List<StorageNode>
        {
            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);
    }
}
// SharepointToolbox.Tests/Services/Export/SearchExportServiceTests.cs
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<SearchResult> { 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<SearchResult>());
        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<SearchResult> { 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<SearchResult> { MakeSample() });
        Assert.Contains("<!DOCTYPE html>", 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<SearchResult> { MakeSample() });
        Assert.Contains("filter", html);        // filter input element
    }

    [Fact]
    public void BuildHtml_WithEmptyList_ReturnsValidHtml()
    {
        var svc = new SearchHtmlExportService();
        var html = svc.BuildHtml(new List<SearchResult>());
        Assert.Contains("<!DOCTYPE html>", html);
    }
}
// SharepointToolbox.Tests/Services/Export/DuplicatesHtmlExportServiceTests.cs
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<DuplicateGroup> { MakeGroup("report.docx", 3) };
        var html = svc.BuildHtml(groups);
        Assert.Contains("<!DOCTYPE html>", html);
        Assert.Contains("report.docx", html);
    }

    [Fact]
    public void BuildHtml_WithMultipleGroups_AllGroupNamesPresent()
    {
        var svc = new DuplicatesHtmlExportService();
        var groups = new List<DuplicateGroup>
        {
            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<DuplicateGroup>());
        Assert.Contains("<!DOCTYPE html>", html);
    }
}

Verification:

dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~DuplicatesServiceTests" -x

Expected: 5 real tests pass (MakeKey logic tests), CSOM stubs skip

Verification

dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~DuplicatesServiceTests|FullyQualifiedName~StorageServiceTests" -x

Expected: 0 build errors; 7 DuplicatesServiceTests+StorageServiceTests pass or skip with no CS errors

Note on unfiltered test run at Wave 0: Running dotnet test without a filter at this stage will show approximately 15 failing tests across StorageCsvExportServiceTests, StorageHtmlExportServiceTests, SearchExportServiceTests, and DuplicatesHtmlExportServiceTests. This is expected — all 5 export service stubs return string.Empty until Plans 03-03 and 03-05 implement the real logic. Do not treat these failures as a blocker for Wave 0 completion.

Commit Message

feat(03-01): create Phase 3 models, interfaces, export stubs, and test scaffolds

Output

After completion, create .planning/phases/03-storage/03-01-SUMMARY.md