---
phase: 04
plan: 01
title: Dependencies + Core Models + Interfaces + BulkOperationRunner + Test Scaffolds
status: pending
wave: 0
depends_on: []
files_modified:
  - 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
autonomous: true
requirements:
  - BULK-04
  - BULK-05

must_haves:
  truths:
    - "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"
  artifacts:
    - path: "SharepointToolbox/Services/BulkOperationRunner.cs"
      provides: "Shared bulk operation helper with continue-on-error"
      exports: ["BulkOperationRunner", "BulkOperationRunner.RunAsync"]
    - path: "SharepointToolbox/Core/Models/BulkOperationResult.cs"
      provides: "Per-item result tracking models"
      exports: ["BulkItemResult<T>", "BulkOperationSummary<T>"]
    - path: "SharepointToolbox/Core/Models/SiteTemplate.cs"
      provides: "Template JSON model"
      exports: ["SiteTemplate"]
  key_links:
    - from: "BulkOperationRunner.cs"
      to: "BulkOperationResult.cs"
      via: "returns BulkOperationSummary<T>"
      pattern: "BulkOperationSummary"
    - from: "BulkOperationRunnerTests.cs"
      to: "BulkOperationRunner.cs"
      via: "unit tests for continue-on-error and cancellation"
      pattern: "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:
```bash
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):
```bash
dotnet add SharepointToolbox.Tests/SharepointToolbox.Tests.csproj package CsvHelper --version 33.1.0
```

2. Create `ConflictPolicy.cs`:
```csharp
namespace SharepointToolbox.Core.Models;

public enum ConflictPolicy
{
    Skip,
    Overwrite,
    Rename
}
```

3. Create `TransferMode.cs`:
```csharp
namespace SharepointToolbox.Core.Models;

public enum TransferMode
{
    Copy,
    Move
}
```

4. Create `BulkOperationResult.cs` with three types:
```csharp
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;
    }
}
```

5. Create `BulkMemberRow.cs` — CSV row for bulk member addition:
```csharp
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"
}
```

6. Create `BulkSiteRow.cs` — CSV row for bulk site creation (matches existing example CSV with semicolon delimiter):
```csharp
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
}
```

7. Create `TransferJob.cs`:
```csharp
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;
}
```

8. Create `FolderStructureRow.cs`:
```csharp
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);
    }
}
```

9. Create `SiteTemplateOptions.cs`:
```csharp
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;
}
```

10. Create `TemplateLibraryInfo.cs`:
```csharp
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();
}
```

11. Create `TemplateFolderInfo.cs`:
```csharp
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();
}
```

12. Create `TemplatePermissionGroup.cs`:
```csharp
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"
}
```

13. Create `SiteTemplate.cs`:
```csharp
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;
}
```

14. Create `CsvValidationRow.cs`:
```csharp
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:**
```bash
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:
```csharp
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);
    }
}
```

2. Create `IFileTransferService.cs`:
```csharp
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);
}
```

3. Create `IBulkMemberService.cs`:
```csharp
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);
}
```

4. Create `IBulkSiteService.cs`:
```csharp
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);
}
```

5. Create `ITemplateService.cs`:
```csharp
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);
}
```

6. Create `IFolderStructureService.cs`:
```csharp
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);
}
```

7. Create `ICsvValidationService.cs`:
```csharp
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);
}
```

8. Create `Export/BulkResultCsvExportService.cs` (stub — implemented in full later, but must compile for test scaffolds):
```csharp
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);
    }
}
```

9. Create `BulkOperationRunnerTests.cs`:
```csharp
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);
    }
}
```

10. Create `CsvValidationServiceTests.cs` (scaffold — tests skip until service is implemented in Plan 04-02):
```csharp
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()
    {
    }
}
```

11. Create `TemplateRepositoryTests.cs` (scaffold):
```csharp
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()
    {
    }
}
```

12. Create `BulkResultCsvExportServiceTests.cs`:
```csharp
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:**
```bash
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`
