Files
Sharepoint-Toolbox/.planning/milestones/v1.0-phases/04-bulk-operations-and-provisioning/04-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

26 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
04 01 Dependencies + Core Models + Interfaces + BulkOperationRunner + Test Scaffolds pending 0
SharepointToolbox/SharepointToolbox.csproj
SharepointToolbox.Tests/SharepointToolbox.Tests.csproj
SharepointToolbox/Core/Models/BulkOperationResult.cs
SharepointToolbox/Core/Models/BulkMemberRow.cs
SharepointToolbox/Core/Models/BulkSiteRow.cs
SharepointToolbox/Core/Models/TransferJob.cs
SharepointToolbox/Core/Models/FolderStructureRow.cs
SharepointToolbox/Core/Models/SiteTemplate.cs
SharepointToolbox/Core/Models/SiteTemplateOptions.cs
SharepointToolbox/Core/Models/TemplateLibraryInfo.cs
SharepointToolbox/Core/Models/TemplateFolderInfo.cs
SharepointToolbox/Core/Models/TemplatePermissionGroup.cs
SharepointToolbox/Core/Models/ConflictPolicy.cs
SharepointToolbox/Core/Models/TransferMode.cs
SharepointToolbox/Core/Models/CsvValidationRow.cs
SharepointToolbox/Services/BulkOperationRunner.cs
SharepointToolbox/Services/IFileTransferService.cs
SharepointToolbox/Services/IBulkMemberService.cs
SharepointToolbox/Services/IBulkSiteService.cs
SharepointToolbox/Services/ITemplateService.cs
SharepointToolbox/Services/IFolderStructureService.cs
SharepointToolbox/Services/ICsvValidationService.cs
SharepointToolbox/Services/Export/BulkResultCsvExportService.cs
SharepointToolbox.Tests/Services/BulkOperationRunnerTests.cs
SharepointToolbox.Tests/Services/CsvValidationServiceTests.cs
SharepointToolbox.Tests/Services/TemplateRepositoryTests.cs
SharepointToolbox.Tests/Services/BulkResultCsvExportServiceTests.cs
true
BULK-04
BULK-05
truths artifacts key_links
CsvHelper 33.1.0 and Microsoft.Graph 5.74.0 are installed and dotnet build succeeds
BulkOperationRunner.RunAsync continues on error and collects per-item results
BulkOperationRunner.RunAsync propagates OperationCanceledException on cancellation
All service interfaces compile and define the expected method signatures
All model classes compile with correct properties
Test scaffolds compile and failing tests are marked with Skip
path provides exports
SharepointToolbox/Services/BulkOperationRunner.cs Shared bulk operation helper with continue-on-error
BulkOperationRunner
BulkOperationRunner.RunAsync
path provides exports
SharepointToolbox/Core/Models/BulkOperationResult.cs Per-item result tracking models
BulkItemResult<T>
BulkOperationSummary<T>
path provides exports
SharepointToolbox/Core/Models/SiteTemplate.cs Template JSON model
SiteTemplate
from to via pattern
BulkOperationRunner.cs BulkOperationResult.cs returns BulkOperationSummary<T> BulkOperationSummary
from to via pattern
BulkOperationRunnerTests.cs BulkOperationRunner.cs unit tests for continue-on-error and cancellation BulkOperationRunner.RunAsync

Plan 04-01: Dependencies + Core Models + Interfaces + BulkOperationRunner + Test Scaffolds

Goal

Install new NuGet packages (CsvHelper 33.1.0, Microsoft.Graph 5.74.0), create all core models for Phase 4, define all service interfaces, implement the shared BulkOperationRunner, create a BulkResultCsvExportService stub, and scaffold test files for Wave 0 coverage.

Context

This is the foundation plan for Phase 4. Every subsequent plan depends on the models, interfaces, and BulkOperationRunner created here. The project uses .NET 10, PnP.Framework 1.18.0, CommunityToolkit.Mvvm 8.4.2. Solution file is SharepointToolbox.slnx.

Existing patterns:

  • Models are plain classes in Core/Models/ with public get/set properties (not records — System.Text.Json requirement)
  • Service interfaces in Services/ with I prefix
  • OperationProgress(int Current, int Total, string Message) already exists
  • SettingsRepository pattern (atomic JSON write with .tmp + File.Move) for persistence

Tasks

Task 1: Install NuGet packages and create all core models + enums

Files:

  • SharepointToolbox/SharepointToolbox.csproj
  • SharepointToolbox.Tests/SharepointToolbox.Tests.csproj
  • SharepointToolbox/Core/Models/BulkOperationResult.cs
  • SharepointToolbox/Core/Models/BulkMemberRow.cs
  • SharepointToolbox/Core/Models/BulkSiteRow.cs
  • SharepointToolbox/Core/Models/TransferJob.cs
  • SharepointToolbox/Core/Models/FolderStructureRow.cs
  • SharepointToolbox/Core/Models/SiteTemplate.cs
  • SharepointToolbox/Core/Models/SiteTemplateOptions.cs
  • SharepointToolbox/Core/Models/TemplateLibraryInfo.cs
  • SharepointToolbox/Core/Models/TemplateFolderInfo.cs
  • SharepointToolbox/Core/Models/TemplatePermissionGroup.cs
  • SharepointToolbox/Core/Models/ConflictPolicy.cs
  • SharepointToolbox/Core/Models/TransferMode.cs
  • SharepointToolbox/Core/Models/CsvValidationRow.cs

Action:

  1. Install NuGet packages:
dotnet add SharepointToolbox/SharepointToolbox.csproj package CsvHelper --version 33.1.0
dotnet add SharepointToolbox/SharepointToolbox.csproj package Microsoft.Graph --version 5.74.0

Also add CsvHelper to the test project (needed for generating test CSVs):

dotnet add SharepointToolbox.Tests/SharepointToolbox.Tests.csproj package CsvHelper --version 33.1.0
  1. Create ConflictPolicy.cs:
namespace SharepointToolbox.Core.Models;

public enum ConflictPolicy
{
    Skip,
    Overwrite,
    Rename
}
  1. Create TransferMode.cs:
namespace SharepointToolbox.Core.Models;

public enum TransferMode
{
    Copy,
    Move
}
  1. Create BulkOperationResult.cs with three types:
namespace SharepointToolbox.Core.Models;

public class BulkItemResult<T>
{
    public T Item { get; }
    public bool IsSuccess { get; }
    public string? ErrorMessage { get; }
    public DateTime Timestamp { get; }

    private BulkItemResult(T item, bool success, string? error)
    {
        Item = item;
        IsSuccess = success;
        ErrorMessage = error;
        Timestamp = DateTime.UtcNow;
    }

    public static BulkItemResult<T> Success(T item) => new(item, true, null);
    public static BulkItemResult<T> Failed(T item, string error) => new(item, false, error);
}

public class BulkOperationSummary<T>
{
    public IReadOnlyList<BulkItemResult<T>> Results { get; }
    public int TotalCount => Results.Count;
    public int SuccessCount => Results.Count(r => r.IsSuccess);
    public int FailedCount => Results.Count(r => !r.IsSuccess);
    public bool HasFailures => FailedCount > 0;
    public IReadOnlyList<BulkItemResult<T>> FailedItems => Results.Where(r => !r.IsSuccess).ToList();

    public BulkOperationSummary(IReadOnlyList<BulkItemResult<T>> results)
    {
        Results = results;
    }
}
  1. Create BulkMemberRow.cs — CSV row for bulk member addition:
using CsvHelper.Configuration.Attributes;

namespace SharepointToolbox.Core.Models;

public class BulkMemberRow
{
    [Name("GroupName")]
    public string GroupName { get; set; } = string.Empty;

    [Name("GroupUrl")]
    public string GroupUrl { get; set; } = string.Empty;

    [Name("Email")]
    public string Email { get; set; } = string.Empty;

    [Name("Role")]
    public string Role { get; set; } = string.Empty; // "Member" or "Owner"
}
  1. Create BulkSiteRow.cs — CSV row for bulk site creation (matches existing example CSV with semicolon delimiter):
using CsvHelper.Configuration.Attributes;

namespace SharepointToolbox.Core.Models;

public class BulkSiteRow
{
    [Name("Name")]
    public string Name { get; set; } = string.Empty;

    [Name("Alias")]
    public string Alias { get; set; } = string.Empty;

    [Name("Type")]
    public string Type { get; set; } = string.Empty; // "Team" or "Communication"

    [Name("Template")]
    public string Template { get; set; } = string.Empty;

    [Name("Owners")]
    public string Owners { get; set; } = string.Empty; // comma-separated emails

    [Name("Members")]
    public string Members { get; set; } = string.Empty; // comma-separated emails
}
  1. Create TransferJob.cs:
namespace SharepointToolbox.Core.Models;

public class TransferJob
{
    public string SourceSiteUrl { get; set; } = string.Empty;
    public string SourceLibrary { get; set; } = string.Empty;
    public string SourceFolderPath { get; set; } = string.Empty; // relative within library
    public string DestinationSiteUrl { get; set; } = string.Empty;
    public string DestinationLibrary { get; set; } = string.Empty;
    public string DestinationFolderPath { get; set; } = string.Empty;
    public TransferMode Mode { get; set; } = TransferMode.Copy;
    public ConflictPolicy ConflictPolicy { get; set; } = ConflictPolicy.Skip;
}
  1. Create FolderStructureRow.cs:
using CsvHelper.Configuration.Attributes;

namespace SharepointToolbox.Core.Models;

public class FolderStructureRow
{
    [Name("Level1")]
    public string Level1 { get; set; } = string.Empty;

    [Name("Level2")]
    public string Level2 { get; set; } = string.Empty;

    [Name("Level3")]
    public string Level3 { get; set; } = string.Empty;

    [Name("Level4")]
    public string Level4 { get; set; } = string.Empty;

    /// <summary>
    /// Builds the folder path from non-empty level values (e.g. "Admin/HR/Contracts").
    /// </summary>
    public string BuildPath()
    {
        var parts = new[] { Level1, Level2, Level3, Level4 }
            .Where(s => !string.IsNullOrWhiteSpace(s));
        return string.Join("/", parts);
    }
}
  1. Create SiteTemplateOptions.cs:
namespace SharepointToolbox.Core.Models;

public class SiteTemplateOptions
{
    public bool CaptureLibraries { get; set; } = true;
    public bool CaptureFolders { get; set; } = true;
    public bool CapturePermissionGroups { get; set; } = true;
    public bool CaptureLogo { get; set; } = true;
    public bool CaptureSettings { get; set; } = true;
}
  1. Create TemplateLibraryInfo.cs:
namespace SharepointToolbox.Core.Models;

public class TemplateLibraryInfo
{
    public string Name { get; set; } = string.Empty;
    public string BaseType { get; set; } = string.Empty; // "DocumentLibrary", "GenericList"
    public int BaseTemplate { get; set; }
    public List<TemplateFolderInfo> Folders { get; set; } = new();
}
  1. Create TemplateFolderInfo.cs:
namespace SharepointToolbox.Core.Models;

public class TemplateFolderInfo
{
    public string Name { get; set; } = string.Empty;
    public string RelativePath { get; set; } = string.Empty;
    public List<TemplateFolderInfo> Children { get; set; } = new();
}
  1. Create TemplatePermissionGroup.cs:
namespace SharepointToolbox.Core.Models;

public class TemplatePermissionGroup
{
    public string Name { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public List<string> RoleDefinitions { get; set; } = new(); // e.g. "Full Control", "Contribute"
}
  1. Create SiteTemplate.cs:
namespace SharepointToolbox.Core.Models;

public class SiteTemplate
{
    public string Id { get; set; } = Guid.NewGuid().ToString();
    public string Name { get; set; } = string.Empty;
    public string SourceUrl { get; set; } = string.Empty;
    public DateTime CapturedAt { get; set; }
    public string SiteType { get; set; } = string.Empty; // "Team" or "Communication"
    public SiteTemplateOptions Options { get; set; } = new();
    public TemplateSettings? Settings { get; set; }
    public TemplateLogo? Logo { get; set; }
    public List<TemplateLibraryInfo> Libraries { get; set; } = new();
    public List<TemplatePermissionGroup> PermissionGroups { get; set; } = new();
}

public class TemplateSettings
{
    public string Title { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public int Language { get; set; }
}

public class TemplateLogo
{
    public string LogoUrl { get; set; } = string.Empty;
}
  1. Create CsvValidationRow.cs:
namespace SharepointToolbox.Core.Models;

public class CsvValidationRow<T>
{
    public T? Record { get; }
    public bool IsValid => Errors.Count == 0;
    public List<string> Errors { get; }
    public string? RawRecord { get; }

    public CsvValidationRow(T record, List<string> errors)
    {
        Record = record;
        Errors = errors;
    }

    private CsvValidationRow(string rawRecord, string parseError)
    {
        Record = default;
        RawRecord = rawRecord;
        Errors = new List<string> { parseError };
    }

    public static CsvValidationRow<T> ParseError(string? rawRecord, string error)
        => new(rawRecord ?? string.Empty, error);
}

Verify:

dotnet build SharepointToolbox/SharepointToolbox.csproj --no-restore -q

Done: All 14 model files compile. CsvHelper and Microsoft.Graph packages installed.

Task 2: Create all service interfaces + BulkOperationRunner + export stub + test scaffolds

Files:

  • SharepointToolbox/Services/BulkOperationRunner.cs
  • SharepointToolbox/Services/IFileTransferService.cs
  • SharepointToolbox/Services/IBulkMemberService.cs
  • SharepointToolbox/Services/IBulkSiteService.cs
  • SharepointToolbox/Services/ITemplateService.cs
  • SharepointToolbox/Services/IFolderStructureService.cs
  • SharepointToolbox/Services/ICsvValidationService.cs
  • SharepointToolbox/Services/Export/BulkResultCsvExportService.cs
  • SharepointToolbox.Tests/Services/BulkOperationRunnerTests.cs
  • SharepointToolbox.Tests/Services/CsvValidationServiceTests.cs
  • SharepointToolbox.Tests/Services/TemplateRepositoryTests.cs
  • SharepointToolbox.Tests/Services/BulkResultCsvExportServiceTests.cs

Action:

  1. Create BulkOperationRunner.cs — the shared bulk operation helper:
using SharepointToolbox.Core.Models;

namespace SharepointToolbox.Services;

public static class BulkOperationRunner
{
    /// <summary>
    /// Runs a bulk operation with continue-on-error semantics, per-item result tracking,
    /// and cancellation support. OperationCanceledException propagates immediately.
    /// </summary>
    public static async Task<BulkOperationSummary<TItem>> RunAsync<TItem>(
        IReadOnlyList<TItem> items,
        Func<TItem, int, CancellationToken, Task> processItem,
        IProgress<OperationProgress> progress,
        CancellationToken ct)
    {
        var results = new List<BulkItemResult<TItem>>();
        for (int i = 0; i < items.Count; i++)
        {
            ct.ThrowIfCancellationRequested();
            progress.Report(new OperationProgress(i + 1, items.Count, $"Processing {i + 1}/{items.Count}..."));
            try
            {
                await processItem(items[i], i, ct);
                results.Add(BulkItemResult<TItem>.Success(items[i]));
            }
            catch (OperationCanceledException) { throw; }
            catch (Exception ex)
            {
                results.Add(BulkItemResult<TItem>.Failed(items[i], ex.Message));
            }
        }
        progress.Report(new OperationProgress(items.Count, items.Count, "Complete."));
        return new BulkOperationSummary<TItem>(results);
    }
}
  1. Create IFileTransferService.cs:
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;

namespace SharepointToolbox.Services;

public interface IFileTransferService
{
    /// <summary>
    /// Transfers files/folders from source to destination.
    /// Returns per-item results (file paths as string items).
    /// </summary>
    Task<BulkOperationSummary<string>> TransferAsync(
        ClientContext sourceCtx,
        ClientContext destCtx,
        TransferJob job,
        IProgress<OperationProgress> progress,
        CancellationToken ct);
}
  1. Create IBulkMemberService.cs:
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;

namespace SharepointToolbox.Services;

public interface IBulkMemberService
{
    Task<BulkOperationSummary<BulkMemberRow>> AddMembersAsync(
        ClientContext ctx,
        IReadOnlyList<BulkMemberRow> rows,
        IProgress<OperationProgress> progress,
        CancellationToken ct);
}
  1. Create IBulkSiteService.cs:
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;

namespace SharepointToolbox.Services;

public interface IBulkSiteService
{
    Task<BulkOperationSummary<BulkSiteRow>> CreateSitesAsync(
        ClientContext adminCtx,
        IReadOnlyList<BulkSiteRow> rows,
        IProgress<OperationProgress> progress,
        CancellationToken ct);
}
  1. Create ITemplateService.cs:
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;

namespace SharepointToolbox.Services;

public interface ITemplateService
{
    Task<SiteTemplate> CaptureTemplateAsync(
        ClientContext ctx,
        SiteTemplateOptions options,
        IProgress<OperationProgress> progress,
        CancellationToken ct);

    Task<string> ApplyTemplateAsync(
        ClientContext adminCtx,
        SiteTemplate template,
        string newSiteTitle,
        string newSiteAlias,
        IProgress<OperationProgress> progress,
        CancellationToken ct);
}
  1. Create IFolderStructureService.cs:
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;

namespace SharepointToolbox.Services;

public interface IFolderStructureService
{
    Task<BulkOperationSummary<string>> CreateFoldersAsync(
        ClientContext ctx,
        string libraryTitle,
        IReadOnlyList<FolderStructureRow> rows,
        IProgress<OperationProgress> progress,
        CancellationToken ct);
}
  1. Create ICsvValidationService.cs:
using SharepointToolbox.Core.Models;

namespace SharepointToolbox.Services;

public interface ICsvValidationService
{
    List<CsvValidationRow<T>> ParseAndValidate<T>(Stream csvStream) where T : class;
    List<CsvValidationRow<BulkMemberRow>> ParseAndValidateMembers(Stream csvStream);
    List<CsvValidationRow<BulkSiteRow>> ParseAndValidateSites(Stream csvStream);
    List<CsvValidationRow<FolderStructureRow>> ParseAndValidateFolders(Stream csvStream);
}
  1. Create Export/BulkResultCsvExportService.cs (stub — implemented in full later, but must compile for test scaffolds):
using System.Globalization;
using System.IO;
using System.Text;
using CsvHelper;
using SharepointToolbox.Core.Models;

namespace SharepointToolbox.Services.Export;

public class BulkResultCsvExportService
{
    public string BuildFailedItemsCsv<T>(IReadOnlyList<BulkItemResult<T>> failedItems)
    {
        using var writer = new StringWriter();
        using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);

        csv.WriteHeader<T>();
        csv.WriteField("Error");
        csv.WriteField("Timestamp");
        csv.NextRecord();

        foreach (var item in failedItems.Where(r => !r.IsSuccess))
        {
            csv.WriteRecord(item.Item);
            csv.WriteField(item.ErrorMessage);
            csv.WriteField(item.Timestamp.ToString("o"));
            csv.NextRecord();
        }

        return writer.ToString();
    }

    public async Task WriteFailedItemsCsvAsync<T>(
        IReadOnlyList<BulkItemResult<T>> failedItems,
        string filePath,
        CancellationToken ct)
    {
        var content = BuildFailedItemsCsv(failedItems);
        await System.IO.File.WriteAllTextAsync(filePath, content, new UTF8Encoding(true), ct);
    }
}
  1. Create BulkOperationRunnerTests.cs:
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;

namespace SharepointToolbox.Tests.Services;

public class BulkOperationRunnerTests
{
    [Fact]
    public async Task RunAsync_AllSucceed_ReturnsAllSuccess()
    {
        var items = new List<string> { "a", "b", "c" };
        var progress = new Progress<OperationProgress>();

        var summary = await BulkOperationRunner.RunAsync(
            items,
            (item, idx, ct) => Task.CompletedTask,
            progress,
            CancellationToken.None);

        Assert.Equal(3, summary.TotalCount);
        Assert.Equal(3, summary.SuccessCount);
        Assert.Equal(0, summary.FailedCount);
        Assert.False(summary.HasFailures);
    }

    [Fact]
    public async Task RunAsync_SomeItemsFail_ContinuesAndReportsPerItem()
    {
        var items = new List<string> { "ok1", "fail", "ok2" };
        var progress = new Progress<OperationProgress>();

        var summary = await BulkOperationRunner.RunAsync(
            items,
            (item, idx, ct) =>
            {
                if (item == "fail") throw new InvalidOperationException("Test error");
                return Task.CompletedTask;
            },
            progress,
            CancellationToken.None);

        Assert.Equal(3, summary.TotalCount);
        Assert.Equal(2, summary.SuccessCount);
        Assert.Equal(1, summary.FailedCount);
        Assert.True(summary.HasFailures);
        Assert.Contains(summary.FailedItems, r => r.ErrorMessage == "Test error");
    }

    [Fact]
    public async Task RunAsync_Cancelled_ThrowsOperationCanceled()
    {
        var items = new List<string> { "a", "b", "c" };
        var cts = new CancellationTokenSource();
        cts.Cancel();
        var progress = new Progress<OperationProgress>();

        await Assert.ThrowsAsync<OperationCanceledException>(() =>
            BulkOperationRunner.RunAsync(
                items,
                (item, idx, ct) => Task.CompletedTask,
                progress,
                cts.Token));
    }

    [Fact]
    public async Task RunAsync_CancelledMidOperation_StopsProcessing()
    {
        var items = new List<string> { "a", "b", "c", "d" };
        var cts = new CancellationTokenSource();
        var processedCount = 0;
        var progress = new Progress<OperationProgress>();

        await Assert.ThrowsAsync<OperationCanceledException>(() =>
            BulkOperationRunner.RunAsync(
                items,
                async (item, idx, ct) =>
                {
                    Interlocked.Increment(ref processedCount);
                    if (idx == 1) cts.Cancel(); // cancel after second item
                    await Task.CompletedTask;
                },
                progress,
                cts.Token));

        Assert.True(processedCount <= 3); // should not process all 4
    }

    [Fact]
    public async Task RunAsync_ReportsProgress()
    {
        var items = new List<string> { "a", "b" };
        var progressReports = new List<OperationProgress>();
        var progress = new Progress<OperationProgress>(p => progressReports.Add(p));

        await BulkOperationRunner.RunAsync(
            items,
            (item, idx, ct) => Task.CompletedTask,
            progress,
            CancellationToken.None);

        // Progress is async, give it a moment to flush
        await Task.Delay(100);
        Assert.True(progressReports.Count >= 2);
    }
}
  1. Create CsvValidationServiceTests.cs (scaffold — tests skip until service is implemented in Plan 04-02):
using SharepointToolbox.Core.Models;

namespace SharepointToolbox.Tests.Services;

public class CsvValidationServiceTests
{
    [Fact(Skip = "Implemented in Plan 04-02")]
    public void ParseAndValidateMembers_ValidCsv_ReturnsValidRows()
    {
    }

    [Fact(Skip = "Implemented in Plan 04-02")]
    public void ParseAndValidateMembers_InvalidEmail_ReturnsErrors()
    {
    }

    [Fact(Skip = "Implemented in Plan 04-02")]
    public void ParseAndValidateSites_TeamWithoutOwner_ReturnsError()
    {
    }

    [Fact(Skip = "Implemented in Plan 04-02")]
    public void ParseAndValidateFolders_ValidCsv_ReturnsValidRows()
    {
    }

    [Fact(Skip = "Implemented in Plan 04-02")]
    public void ParseAndValidate_BomDetection_WorksWithAndWithoutBom()
    {
    }

    [Fact(Skip = "Implemented in Plan 04-02")]
    public void ParseAndValidate_SemicolonDelimiter_DetectedAutomatically()
    {
    }
}
  1. Create TemplateRepositoryTests.cs (scaffold):
using SharepointToolbox.Core.Models;

namespace SharepointToolbox.Tests.Services;

public class TemplateRepositoryTests
{
    [Fact(Skip = "Implemented in Plan 04-02")]
    public void SaveAndLoad_RoundTrips_Correctly()
    {
    }

    [Fact(Skip = "Implemented in Plan 04-02")]
    public void GetAll_ReturnsAllSavedTemplates()
    {
    }

    [Fact(Skip = "Implemented in Plan 04-02")]
    public void Delete_RemovesTemplate()
    {
    }

    [Fact(Skip = "Implemented in Plan 04-02")]
    public void Rename_UpdatesTemplateName()
    {
    }
}
  1. Create BulkResultCsvExportServiceTests.cs:
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services.Export;

namespace SharepointToolbox.Tests.Services;

public class BulkResultCsvExportServiceTests
{
    [Fact]
    public void BuildFailedItemsCsv_WithFailedItems_IncludesErrorColumn()
    {
        var service = new BulkResultCsvExportService();
        var items = new List<BulkItemResult<BulkMemberRow>>
        {
            BulkItemResult<BulkMemberRow>.Failed(
                new BulkMemberRow { Email = "bad@test.com", GroupName = "G1", GroupUrl = "http://test", Role = "Member" },
                "User not found"),
        };

        var csv = service.BuildFailedItemsCsv(items);

        Assert.Contains("Error", csv);
        Assert.Contains("Timestamp", csv);
        Assert.Contains("bad@test.com", csv);
        Assert.Contains("User not found", csv);
    }

    [Fact]
    public void BuildFailedItemsCsv_SuccessItems_Excluded()
    {
        var service = new BulkResultCsvExportService();
        var items = new List<BulkItemResult<BulkMemberRow>>
        {
            BulkItemResult<BulkMemberRow>.Success(
                new BulkMemberRow { Email = "ok@test.com", GroupName = "G1", GroupUrl = "http://test", Role = "Member" }),
            BulkItemResult<BulkMemberRow>.Failed(
                new BulkMemberRow { Email = "bad@test.com", GroupName = "G1", GroupUrl = "http://test", Role = "Member" },
                "Error"),
        };

        var csv = service.BuildFailedItemsCsv(items);

        Assert.DoesNotContain("ok@test.com", csv);
        Assert.Contains("bad@test.com", csv);
    }
}

Verify:

dotnet build SharepointToolbox.slnx --no-restore -q && dotnet test SharepointToolbox.Tests --no-build --filter "FullyQualifiedName~BulkOperationRunner|FullyQualifiedName~BulkResultCsvExport" -q

Done: All models, enums, interfaces, BulkOperationRunner, and export stub compile. BulkOperationRunner tests pass (5 tests). BulkResultCsvExportService tests pass (2 tests). Skipped test scaffolds compile. dotnet build succeeds for entire solution.

Commit: feat(04-01): add Phase 4 models, interfaces, BulkOperationRunner, and test scaffolds